dsh-git-ui 0.0.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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/README.zh.md +110 -0
- package/cordis.patch.yml +14 -0
- package/lib/.keep +0 -0
- package/lib/client.js +71 -0
- package/lib/client.js.map +7 -0
- package/lib/host/core.d.ts +53 -0
- package/lib/host/git.d.ts +89 -0
- package/lib/host/index.d.ts +23 -0
- package/lib/host/index.js +380 -0
- package/lib/host/index.js.map +7 -0
- package/lib/host/parser.d.ts +57 -0
- package/lib/host/types.d.ts +74 -0
- package/package.json +80 -0
- package/src/client/GitPill.tsx +354 -0
- package/src/client/controller.ts +158 -0
- package/src/client/index.ts +149 -0
- package/src/client/locales.ts +55 -0
- package/src/client/remote.ts +97 -0
- package/src/client/styles.ts +229 -0
- package/src/client/turn-signal.ts +33 -0
- package/src/host/core.ts +199 -0
- package/src/host/git.ts +152 -0
- package/src/host/index.ts +80 -0
- package/src/host/parser.ts +176 -0
- package/src/host/types.ts +74 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-completion signal for the git pill's activity trigger.
|
|
3
|
+
*
|
|
4
|
+
* The client runtime's ConversationSnapshot carries `turnEnds` — completed
|
|
5
|
+
* turn numbers inside the snapshot window. A finished agent turn is the most
|
|
6
|
+
* likely moment the working tree changed, so the pill watches the highest
|
|
7
|
+
* completed turn and refreshes when it rises (best-effort; polling remains
|
|
8
|
+
* the fallback). Kept React-free so the logic is unit-testable without a
|
|
9
|
+
* browser runtime.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The slice of the conversation snapshot the pill observes for activity.
|
|
14
|
+
* `turnEnds` maps completed turn numbers to their closing event seq inside
|
|
15
|
+
* the snapshot window (source: the client runtime's ConversationSnapshot).
|
|
16
|
+
*/
|
|
17
|
+
export interface TurnSignalSnapshot {
|
|
18
|
+
readonly turnEnds: ReadonlyMap<number, number>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Highest completed turn number in the snapshot window, 0 when none.
|
|
23
|
+
* Monotonic across ordinary activity (a finished turn only ever adds a
|
|
24
|
+
* larger key), so `next > prev` is a reliable "an agent turn just ended"
|
|
25
|
+
* edge — the best-effort trigger for an immediate refresh.
|
|
26
|
+
*/
|
|
27
|
+
export function completedTurnCount(snapshot: TurnSignalSnapshot): number {
|
|
28
|
+
let max = 0
|
|
29
|
+
for (const turn of snapshot.turnEnds.keys()) {
|
|
30
|
+
if (turn > max) max = turn
|
|
31
|
+
}
|
|
32
|
+
return max
|
|
33
|
+
}
|
package/src/host/core.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free snapshot orchestration: session cwd resolution + git command
|
|
3
|
+
* sequence + frozen GitSnapshot assembly. Every dependency is injected
|
|
4
|
+
* structurally, so the whole flow is testable without a cordis runtime; the
|
|
5
|
+
* cordis shell (GitStatusService) only adapts host services into these faces.
|
|
6
|
+
*/
|
|
7
|
+
import { parseBranchOutput, parseLogOutput, parseStatusOutput } from './parser.ts'
|
|
8
|
+
import type { GitRunner } from './git.ts'
|
|
9
|
+
import type { GitSnapshot, GitSnapshotResult } from './types.ts'
|
|
10
|
+
|
|
11
|
+
/** Resolved plugin config (already normalized; see normalizeConfig). */
|
|
12
|
+
export interface GitStatusConfig {
|
|
13
|
+
readonly timeoutMs: number
|
|
14
|
+
readonly maxStatusBytes: number
|
|
15
|
+
readonly maxChanges: number
|
|
16
|
+
readonly defaultRefreshIntervalMs: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Session identity lookup: live first, persisted fallback. */
|
|
20
|
+
export interface SessionLookup {
|
|
21
|
+
/** Live session cwd; undefined when the session is cold or absent in memory. */
|
|
22
|
+
liveCwd(sessionId: string): string | undefined
|
|
23
|
+
/**
|
|
24
|
+
* Persisted session metadata; resolves to undefined when no persisted
|
|
25
|
+
* session exists, and to `{ cwd }` (cwd possibly undefined) otherwise.
|
|
26
|
+
*/
|
|
27
|
+
persistedMeta(sessionId: string): Promise<{ readonly cwd?: string } | undefined>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Filesystem primitives (node:fs/promises slices). */
|
|
31
|
+
export interface FsLike {
|
|
32
|
+
realpath(path: string): Promise<string>
|
|
33
|
+
stat(path: string): Promise<{ isDirectory(): boolean }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everything the snapshot flow needs beyond the session lookup. */
|
|
37
|
+
export interface SnapshotDeps {
|
|
38
|
+
readonly run: GitRunner
|
|
39
|
+
readonly fs: FsLike
|
|
40
|
+
readonly sessions: SessionLookup
|
|
41
|
+
/** Injectable clock for deterministic tests. */
|
|
42
|
+
readonly now?: () => number
|
|
43
|
+
/** Caller-side cancellation (Remote `signal` slot): aborts in-flight git runs. */
|
|
44
|
+
readonly signal?: AbortSignal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Defaults applied by normalizeConfig when a value is absent or invalid. */
|
|
48
|
+
export const DEFAULT_CONFIG: GitStatusConfig = {
|
|
49
|
+
timeoutMs: 5000,
|
|
50
|
+
maxStatusBytes: 4 * 1024 * 1024,
|
|
51
|
+
maxChanges: 100,
|
|
52
|
+
defaultRefreshIntervalMs: 30_000,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Coerce a raw patch config value into a validated GitStatusConfig. */
|
|
56
|
+
export function normalizeConfig(raw: unknown): GitStatusConfig {
|
|
57
|
+
const value = (raw ?? {}) as Record<string, unknown>
|
|
58
|
+
const numberOr = (key: string, fallback: number): number => {
|
|
59
|
+
const candidate = value[key]
|
|
60
|
+
return typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0
|
|
61
|
+
? candidate
|
|
62
|
+
: fallback
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
timeoutMs: numberOr('timeoutMs', DEFAULT_CONFIG.timeoutMs) || DEFAULT_CONFIG.timeoutMs,
|
|
66
|
+
maxStatusBytes: numberOr('maxStatusBytes', DEFAULT_CONFIG.maxStatusBytes) || DEFAULT_CONFIG.maxStatusBytes,
|
|
67
|
+
maxChanges: Math.floor(numberOr('maxChanges', DEFAULT_CONFIG.maxChanges) || DEFAULT_CONFIG.maxChanges),
|
|
68
|
+
defaultRefreshIntervalMs: numberOr('defaultRefreshIntervalMs', DEFAULT_CONFIG.defaultRefreshIntervalMs),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Outcome of the cwd resolution step. */
|
|
73
|
+
type CwdResolution =
|
|
74
|
+
| { readonly ok: true; readonly cwd: string }
|
|
75
|
+
| { readonly ok: false; readonly error: Extract<GitSnapshotResult, { ok: false }>['error'] }
|
|
76
|
+
|
|
77
|
+
async function resolveCwd(sessions: SessionLookup, sessionId: string): Promise<CwdResolution> {
|
|
78
|
+
const live = sessions.liveCwd(sessionId)
|
|
79
|
+
if (live !== undefined) return { ok: true, cwd: live }
|
|
80
|
+
const persisted = await sessions.persistedMeta(sessionId)
|
|
81
|
+
if (persisted === undefined) return { ok: false, error: { code: 'session-not-found', sessionId } }
|
|
82
|
+
if (persisted.cwd === undefined) return { ok: false, error: { code: 'cwd-unavailable', sessionId } }
|
|
83
|
+
return { ok: true, cwd: persisted.cwd }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Classify a failed run outcome into a snapshot failure. */
|
|
87
|
+
function runFailure(result: { readonly timedOut: boolean }, detail: string): Extract<GitSnapshotResult, { ok: false }>['error'] {
|
|
88
|
+
return result.timedOut ? { code: 'timeout' } : { code: 'git-unavailable', detail }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Run one command, mapping a spawn-level failure to a snapshot failure. */
|
|
92
|
+
async function runCommand(
|
|
93
|
+
runner: GitRunner,
|
|
94
|
+
argv: readonly string[],
|
|
95
|
+
cwd: string,
|
|
96
|
+
label: string,
|
|
97
|
+
signal?: AbortSignal,
|
|
98
|
+
): Promise<{ readonly run: Awaited<ReturnType<GitRunner['run']>> } | { readonly failure: Extract<GitSnapshotResult, { ok: false }>['error'] }> {
|
|
99
|
+
try {
|
|
100
|
+
return { run: await runner.run(argv, { cwd, ...(signal === undefined ? {} : { signal }) }) }
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { failure: { code: 'git-unavailable', detail: `${label}: ${error instanceof Error ? error.message : String(error)}` } }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build one frozen GitSnapshot for a session working directory.
|
|
108
|
+
* Command sequence (all read-only; every command after the first runs with
|
|
109
|
+
* the repository root as cwd):
|
|
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`
|
|
115
|
+
*/
|
|
116
|
+
export async function snapshotForSession(
|
|
117
|
+
deps: SnapshotDeps,
|
|
118
|
+
config: GitStatusConfig,
|
|
119
|
+
sessionId: string,
|
|
120
|
+
): Promise<GitSnapshotResult> {
|
|
121
|
+
const resolved = await resolveCwd(deps.sessions, sessionId)
|
|
122
|
+
if (!resolved.ok) return { ok: false, error: resolved.error }
|
|
123
|
+
|
|
124
|
+
let realCwd: string
|
|
125
|
+
try {
|
|
126
|
+
realCwd = await deps.fs.realpath(resolved.cwd)
|
|
127
|
+
const stat = await deps.fs.stat(realCwd)
|
|
128
|
+
if (!stat.isDirectory()) {
|
|
129
|
+
return { ok: false, error: { code: 'path-not-found', path: realCwd } }
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
return { ok: false, error: { code: 'path-not-found', path: resolved.cwd } }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toplevel = await runCommand(deps.run, ['git', 'rev-parse', '--show-toplevel'], realCwd, 'rev-parse', deps.signal)
|
|
136
|
+
if ('failure' in toplevel) return { ok: false, error: toplevel.failure }
|
|
137
|
+
if (toplevel.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
138
|
+
if (toplevel.run.exitCode !== 0) {
|
|
139
|
+
// exit 128 covers both "not a git repository" (plain directory) and
|
|
140
|
+
// other git failures (dubious ownership, unreadable work tree, …).
|
|
141
|
+
// Only the former is a stable non-repo state; everything else surfaces
|
|
142
|
+
// as git-unavailable with the actual reason instead of a misleading
|
|
143
|
+
// "no git repository" pill.
|
|
144
|
+
const stderr = toplevel.run.stderr
|
|
145
|
+
if (!stderr.includes('not a git repository')) {
|
|
146
|
+
return { ok: false, error: runFailure(toplevel.run, `git rev-parse failed: ${stderr.trim() || `exit ${String(toplevel.run.exitCode)}`}`) }
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, error: { code: 'not-a-git-repo' } }
|
|
149
|
+
}
|
|
150
|
+
const root = toplevel.run.stdout.trim()
|
|
151
|
+
if (root === '') return { ok: false, error: { code: 'not-a-git-repo' } }
|
|
152
|
+
|
|
153
|
+
const branchRun = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
|
|
154
|
+
if ('failure' in branchRun) return { ok: false, error: branchRun.failure }
|
|
155
|
+
if (branchRun.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
156
|
+
const branch = branchRun.run.exitCode === 0 ? parseBranchOutput(branchRun.run.stdout) : null
|
|
157
|
+
|
|
158
|
+
const headRun = await runCommand(deps.run, ['git', 'rev-parse', '--short', 'HEAD'], root, 'rev-parse HEAD', deps.signal)
|
|
159
|
+
if ('failure' in headRun) return { ok: false, error: headRun.failure }
|
|
160
|
+
if (headRun.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
161
|
+
// A failed HEAD read (non-timeout) only nulls the hash: the authoritative
|
|
162
|
+
// unborn flag comes from the status header below (`## No commits yet on
|
|
163
|
+
// main`), so a corrupt repo is never misreported as "no commits".
|
|
164
|
+
const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
|
|
165
|
+
|
|
166
|
+
const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch'], root, 'status', deps.signal)
|
|
167
|
+
if ('failure' in status) return { ok: false, error: status.failure }
|
|
168
|
+
if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
169
|
+
if (status.run.exitCode !== 0) {
|
|
170
|
+
return { ok: false, error: runFailure(status.run, `git status exited ${String(status.run.exitCode)}`) }
|
|
171
|
+
}
|
|
172
|
+
const parsed = parseStatusOutput(status.run.stdout, config.maxChanges)
|
|
173
|
+
|
|
174
|
+
const log = await runCommand(deps.run, ['git', 'log', '-n', '5', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI'], root, 'log', deps.signal)
|
|
175
|
+
if ('failure' in log) return { ok: false, error: log.failure }
|
|
176
|
+
if (log.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
177
|
+
const recentCommits = log.run.exitCode === 0 ? parseLogOutput(log.run.stdout) : []
|
|
178
|
+
|
|
179
|
+
const checkedAt = deps.now?.() ?? Date.now()
|
|
180
|
+
const snapshot: GitSnapshot = {
|
|
181
|
+
root,
|
|
182
|
+
branch,
|
|
183
|
+
head,
|
|
184
|
+
unborn: parsed.unborn,
|
|
185
|
+
dirty: parsed.staged + parsed.modified + parsed.untracked > 0,
|
|
186
|
+
staged: parsed.staged,
|
|
187
|
+
modified: parsed.modified,
|
|
188
|
+
untracked: parsed.untracked,
|
|
189
|
+
ahead: parsed.ahead,
|
|
190
|
+
behind: parsed.behind,
|
|
191
|
+
lastCommit: recentCommits[0] ?? null,
|
|
192
|
+
recentCommits,
|
|
193
|
+
changes: parsed.changes,
|
|
194
|
+
truncated: parsed.truncated || ('run' in status && status.run.stdoutLossy),
|
|
195
|
+
refreshIntervalMs: config.defaultRefreshIntervalMs,
|
|
196
|
+
checkedAt,
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value: snapshot }
|
|
199
|
+
}
|
package/src/host/git.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git command execution adapter over the host subprocess service.
|
|
3
|
+
*
|
|
4
|
+
* The widget only needs a tiny slice of the subprocess contract; declaring it
|
|
5
|
+
* structurally here (instead of depending on the npm package, whose registry
|
|
6
|
+
* chain is incomplete) keeps the plugin buildable standalone while remaining
|
|
7
|
+
* wire-compatible with the host's `subprocess` service.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from 'node:fs/promises'
|
|
10
|
+
|
|
11
|
+
/** One collected stream disposition (matches the host SubprocessCollect). */
|
|
12
|
+
interface CollectDisposition {
|
|
13
|
+
readonly collect: {
|
|
14
|
+
readonly maxBytes: number
|
|
15
|
+
/**
|
|
16
|
+
* Spill disposition: when the stream overflows the in-memory tail, the
|
|
17
|
+
* host appends the COMPLETE stream to a private spill file (up to this
|
|
18
|
+
* cap) and `readFrom` reports its path. Without it, only the tail is
|
|
19
|
+
* ever retained and the head (and its change counts) is lost.
|
|
20
|
+
*/
|
|
21
|
+
readonly spill?: { readonly maxBytes: number }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Structural slice of the host subprocess spawn spec. */
|
|
26
|
+
interface SpawnSpec {
|
|
27
|
+
readonly argv: readonly string[]
|
|
28
|
+
readonly cwd: string
|
|
29
|
+
readonly stdio: {
|
|
30
|
+
readonly stdout: CollectDisposition
|
|
31
|
+
readonly stderr: CollectDisposition
|
|
32
|
+
}
|
|
33
|
+
readonly graceMs: number
|
|
34
|
+
readonly signal?: AbortSignal
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Structural slice of the host subprocess handle (collect-mode output). */
|
|
38
|
+
interface SpawnHandle {
|
|
39
|
+
readonly done: Promise<{ readonly exitCode: number | null; readonly signal: NodeJS.Signals | null }>
|
|
40
|
+
readonly collected: {
|
|
41
|
+
readonly stdout?: {
|
|
42
|
+
readFrom(fromByte: number): { readonly text: string; readonly lossy: boolean; readonly spillPath?: string }
|
|
43
|
+
}
|
|
44
|
+
readonly stderr?: {
|
|
45
|
+
readFrom(fromByte: number): { readonly text: string; readonly lossy: boolean; readonly spillPath?: string }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal subprocess-service face the adapter consumes. */
|
|
51
|
+
export interface SubprocessLike {
|
|
52
|
+
spawn(spec: SpawnSpec): SpawnHandle
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One git command outcome. */
|
|
56
|
+
export interface GitRunResult {
|
|
57
|
+
/** Process exit code; null when terminated by a signal. */
|
|
58
|
+
readonly exitCode: number | null
|
|
59
|
+
readonly stdout: string
|
|
60
|
+
readonly stderr: string
|
|
61
|
+
/** True when the run was killed by our timeout (or the caller's signal). */
|
|
62
|
+
readonly timedOut: boolean
|
|
63
|
+
/**
|
|
64
|
+
* True when the final stdout text is still incomplete: the collected
|
|
65
|
+
* output overflowed its byte cap AND the spill file was unavailable (no
|
|
66
|
+
* spill configured on the host, or the spill cap also overflowed).
|
|
67
|
+
*/
|
|
68
|
+
readonly stdoutLossy: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The run primitive the snapshot orchestration uses. */
|
|
72
|
+
export interface GitRunner {
|
|
73
|
+
run(argv: readonly string[], opts: { readonly cwd: string; readonly signal?: AbortSignal }): Promise<GitRunResult>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Adapt the host subprocess service into a `GitRunner` with a per-command
|
|
78
|
+
* timeout. A timed-out run resolves (never rejects) with `timedOut: true`;
|
|
79
|
+
* only spawn-level failures (e.g. git not installed) reject.
|
|
80
|
+
*
|
|
81
|
+
* Overflow handling: stdout/stderr collect with a spill cap of
|
|
82
|
+
* `maxBytes * 16` (default 4 MiB memory tail → 64 MiB spill file). When the
|
|
83
|
+
* tail overflowed but the spill file holds the complete stream, the runner
|
|
84
|
+
* reads the file and reports `stdoutLossy: false` — the change COUNTS stay
|
|
85
|
+
* exact. `stdoutLossy: true` is reserved for the doubly-overflowed case
|
|
86
|
+
* (spill also exceeded), where the head is genuinely lost.
|
|
87
|
+
*/
|
|
88
|
+
export function createGitRunner(subprocess: SubprocessLike, timeoutMs: number, maxBytes: number): GitRunner {
|
|
89
|
+
const spillMaxBytes = maxBytes * 16
|
|
90
|
+
return {
|
|
91
|
+
async run(argv, opts) {
|
|
92
|
+
const controller = new AbortController()
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
94
|
+
try {
|
|
95
|
+
const signal = opts.signal === undefined
|
|
96
|
+
? controller.signal
|
|
97
|
+
: AbortSignal.any([controller.signal, opts.signal])
|
|
98
|
+
const handle = subprocess.spawn({
|
|
99
|
+
argv,
|
|
100
|
+
cwd: opts.cwd,
|
|
101
|
+
stdio: {
|
|
102
|
+
stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
|
|
103
|
+
stderr: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
|
|
104
|
+
},
|
|
105
|
+
graceMs: 200,
|
|
106
|
+
signal,
|
|
107
|
+
})
|
|
108
|
+
let outcome: Awaited<SpawnHandle['done']>
|
|
109
|
+
try {
|
|
110
|
+
// `done` rejects for spawn-level failures; an abort-triggered
|
|
111
|
+
// rejection is the timeout path and resolves as timedOut.
|
|
112
|
+
outcome = await handle.done
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (controller.signal.aborted || opts.signal?.aborted === true) {
|
|
115
|
+
return { exitCode: null, stdout: '', stderr: '', timedOut: true, stdoutLossy: false }
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
120
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
121
|
+
const stdoutResolved = await resolveStdout(stdout)
|
|
122
|
+
return {
|
|
123
|
+
exitCode: outcome.exitCode,
|
|
124
|
+
stdout: stdoutResolved.text,
|
|
125
|
+
stderr: stderr?.text ?? '',
|
|
126
|
+
timedOut: controller.signal.aborted || opts.signal?.aborted === true,
|
|
127
|
+
stdoutLossy: stdoutResolved.lossy,
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the stdout text from a collect read: the in-memory tail, or — when
|
|
138
|
+
* the read is lossy and the host spilled the complete stream to a file — the
|
|
139
|
+
* spill file contents (so change COUNTS stay exact). A failed spill read
|
|
140
|
+
* falls back to the tail and keeps `lossy: true` (head genuinely lost).
|
|
141
|
+
*/
|
|
142
|
+
async function resolveStdout(
|
|
143
|
+
read: { readonly text: string; readonly lossy: boolean; readonly spillPath?: string } | undefined,
|
|
144
|
+
): Promise<{ readonly text: string; readonly lossy: boolean }> {
|
|
145
|
+
if (read === undefined) return { text: '', lossy: false }
|
|
146
|
+
if (!read.lossy || read.spillPath === undefined) return { text: read.text, lossy: read.lossy }
|
|
147
|
+
try {
|
|
148
|
+
return { text: await readFile(read.spillPath, 'utf8'), lossy: false }
|
|
149
|
+
} catch {
|
|
150
|
+
return { text: read.text, lossy: true }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-ui host half: the `gitInfo` Remote service.
|
|
3
|
+
*
|
|
4
|
+
* Cordis shell only — every behavior lives in `core.ts` behind injected
|
|
5
|
+
* structural faces, so tests never need a cordis runtime. The class is a
|
|
6
|
+
* plugin in its own right (class form), mounted by the bundle patch row with
|
|
7
|
+
* the package name; the gateway exposes `gitInfo/snapshot` through SRC
|
|
8
|
+
* discovery (`typertRemote` binding + `@Remote` marker).
|
|
9
|
+
*/
|
|
10
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
13
|
+
import { createGitRunner, type SubprocessLike } from './git.ts'
|
|
14
|
+
import { normalizeConfig, snapshotForSession, type GitStatusConfig } from './core.ts'
|
|
15
|
+
import type { GitSnapshotRequest, GitSnapshotResult } from './types.ts'
|
|
16
|
+
|
|
17
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange } from './types.ts'
|
|
18
|
+
export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
|
|
19
|
+
export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
|
|
20
|
+
|
|
21
|
+
/** Structural face of a live session header. */
|
|
22
|
+
interface SessionLike {
|
|
23
|
+
readonly header?: { readonly cwd?: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Structural face of the sessions service. */
|
|
27
|
+
interface SessionsLike {
|
|
28
|
+
get(id: string): SessionLike | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Structural face of the session-persistence service. */
|
|
32
|
+
interface SessionPersistenceLike {
|
|
33
|
+
inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `gitInfo` service: one `snapshot` Remote endpoint. */
|
|
37
|
+
export class GitStatusService extends TypertRemoteService {
|
|
38
|
+
static inject = ['subprocess', 'sessions', 'sessionPersistence']
|
|
39
|
+
|
|
40
|
+
private readonly config: GitStatusConfig
|
|
41
|
+
|
|
42
|
+
constructor(ctx: Context, config: unknown) {
|
|
43
|
+
super(ctx, 'gitInfo')
|
|
44
|
+
this.config = normalizeConfig(config)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Remote('snapshot')
|
|
48
|
+
async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
|
|
49
|
+
const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
|
|
50
|
+
if (subprocess === undefined) {
|
|
51
|
+
return { ok: false, error: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
|
|
52
|
+
}
|
|
53
|
+
const sessions = this.ctx.get('sessions') as SessionsLike | undefined
|
|
54
|
+
const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
|
|
55
|
+
const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
|
|
56
|
+
return snapshotForSession(
|
|
57
|
+
{
|
|
58
|
+
run: runner,
|
|
59
|
+
fs: { realpath, stat },
|
|
60
|
+
sessions: {
|
|
61
|
+
liveCwd: (id) => sessions?.get(id)?.header?.cwd,
|
|
62
|
+
persistedMeta: async (id) => {
|
|
63
|
+
if (persistence === undefined) return undefined
|
|
64
|
+
try {
|
|
65
|
+
const inspection = await persistence.inspect(id)
|
|
66
|
+
return { cwd: inspection.meta.cwd }
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
signal,
|
|
73
|
+
},
|
|
74
|
+
this.config,
|
|
75
|
+
request.sessionId,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default GitStatusService
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers for the git porcelain/log output shapes used by the widget.
|
|
3
|
+
* No side effects and no I/O — fully unit-testable against literal fixtures
|
|
4
|
+
* (verified against real `git status --porcelain=v1 -z --branch` output).
|
|
5
|
+
*/
|
|
6
|
+
import type { GitChange, GitChangeStatus, GitCommit } from './types.ts'
|
|
7
|
+
|
|
8
|
+
/** Parsed status counts plus the (possibly capped) change list. */
|
|
9
|
+
export interface ParsedStatus {
|
|
10
|
+
readonly branch: string | null
|
|
11
|
+
readonly unborn: boolean
|
|
12
|
+
readonly staged: number
|
|
13
|
+
readonly modified: number
|
|
14
|
+
readonly untracked: number
|
|
15
|
+
readonly ahead: number
|
|
16
|
+
readonly behind: number
|
|
17
|
+
readonly changes: readonly GitChange[]
|
|
18
|
+
readonly truncated: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The NUL byte separating porcelain v1 -z entries. */
|
|
22
|
+
const NUL = '\u0000'
|
|
23
|
+
/** The unit separator used by the log --format payload. */
|
|
24
|
+
const LOG_SEP = '\u001f'
|
|
25
|
+
|
|
26
|
+
interface StatusHeader {
|
|
27
|
+
readonly branch: string | null
|
|
28
|
+
readonly unborn: boolean
|
|
29
|
+
readonly ahead: number
|
|
30
|
+
readonly behind: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the `## ` header line of `git status --porcelain=v1 -z --branch`.
|
|
35
|
+
* Recognized shapes (verified against git 2.x):
|
|
36
|
+
* `## main`
|
|
37
|
+
* `## main...origin/main`
|
|
38
|
+
* `## main...origin/main [ahead 1]`
|
|
39
|
+
* `## main...origin/main [behind 2]`
|
|
40
|
+
* `## main...origin/main [ahead 1, behind 2]`
|
|
41
|
+
* `## HEAD (no branch)` (detached)
|
|
42
|
+
* `## HEAD (detached at <hash>)` (detached, older git)
|
|
43
|
+
* `## No commits yet on main` (unborn)
|
|
44
|
+
* `## Initial commit on main` (unborn, older git)
|
|
45
|
+
*/
|
|
46
|
+
export function parseStatusHeader(line: string): StatusHeader {
|
|
47
|
+
const body = line.startsWith('## ') ? line.slice(3) : line
|
|
48
|
+
if (body === '') return { branch: null, unborn: false, ahead: 0, behind: 0 }
|
|
49
|
+
|
|
50
|
+
const unbornMatch = /^(?:No commits yet on|Initial commit on)\s+(.+)$/.exec(body)
|
|
51
|
+
if (unbornMatch !== null) {
|
|
52
|
+
return { branch: unbornMatch[1] ?? null, unborn: true, ahead: 0, behind: 0 }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const detached = /^HEAD(?:\s+\([^)]*\))?$/.exec(body)
|
|
56
|
+
if (detached !== null) {
|
|
57
|
+
return { branch: null, unborn: false, ahead: 0, behind: 0 }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bracketMatch = /^(.*?)\s*\[([^\]]+)\]$/.exec(body)
|
|
61
|
+
const core = bracketMatch?.[1] ?? body
|
|
62
|
+
let ahead = 0
|
|
63
|
+
let behind = 0
|
|
64
|
+
if (bracketMatch?.[2] !== undefined) {
|
|
65
|
+
for (const part of bracketMatch[2].split(',')) {
|
|
66
|
+
const trimmed = part.trim()
|
|
67
|
+
const aheadMatch = /^ahead (\d+)$/.exec(trimmed)
|
|
68
|
+
const behindMatch = /^behind (\d+)$/.exec(trimmed)
|
|
69
|
+
if (aheadMatch !== null) ahead = Number(aheadMatch[1])
|
|
70
|
+
if (behindMatch !== null) behind = Number(behindMatch[1])
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// The core is `<branch>...<upstream>` — the branch never contains `...`.
|
|
74
|
+
const branch = core.split('...', 1)[0] ?? core
|
|
75
|
+
return { branch: branch === '' ? null : branch, unborn: false, ahead, behind }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Map one porcelain XY pair to a change status. */
|
|
79
|
+
function changeStatus(x: string, y: string): GitChangeStatus {
|
|
80
|
+
if (x === '?' && y === '?') return 'untracked'
|
|
81
|
+
if (x === 'U' || y === 'U' || (x !== ' ' && y !== ' ')) return 'conflicted'
|
|
82
|
+
switch (x) {
|
|
83
|
+
case 'A': return 'added'
|
|
84
|
+
case 'M': return 'modified'
|
|
85
|
+
case 'D': return 'deleted'
|
|
86
|
+
case 'R': return 'renamed'
|
|
87
|
+
case 'T': return 'typechange'
|
|
88
|
+
case 'C': return 'added'
|
|
89
|
+
default: return 'modified'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse the full `git status --porcelain=v1 -z --branch` output.
|
|
95
|
+
* -z format: every entry (header and each `XY path`) is NUL-terminated; a
|
|
96
|
+
* rename/copy entry emits `R <new>\0<old>\0` so the following item is the
|
|
97
|
+
* source path and must be consumed without becoming a change itself.
|
|
98
|
+
*/
|
|
99
|
+
export function parseStatusOutput(output: string, maxChanges: number): ParsedStatus {
|
|
100
|
+
const raw = output.split(NUL)
|
|
101
|
+
// Trailing NUL produces a final empty segment; drop it.
|
|
102
|
+
const segments = raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw
|
|
103
|
+
const header = parseStatusHeader(segments[0] ?? '')
|
|
104
|
+
|
|
105
|
+
let staged = 0
|
|
106
|
+
let modified = 0
|
|
107
|
+
let untracked = 0
|
|
108
|
+
const changes: GitChange[] = []
|
|
109
|
+
let truncated = false
|
|
110
|
+
|
|
111
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
112
|
+
const entry = segments[index] ?? ''
|
|
113
|
+
const x = entry[0] ?? ' '
|
|
114
|
+
const y = entry[1] ?? ' '
|
|
115
|
+
const path = entry.slice(3)
|
|
116
|
+
if (x === ' ' && y === ' ') continue
|
|
117
|
+
if (x === 'R' || x === 'C') {
|
|
118
|
+
// -z: the source path is the next segment — consume it.
|
|
119
|
+
index += 1
|
|
120
|
+
}
|
|
121
|
+
if (x === '?' && y === '?') {
|
|
122
|
+
untracked += 1
|
|
123
|
+
} else {
|
|
124
|
+
if (x !== ' ' && x !== '?') staged += 1
|
|
125
|
+
if (y !== ' ' && y !== '?') modified += 1
|
|
126
|
+
}
|
|
127
|
+
if (changes.length < maxChanges) {
|
|
128
|
+
changes.push({ path, status: changeStatus(x, y), staged: x !== ' ' && x !== '?' })
|
|
129
|
+
} else {
|
|
130
|
+
truncated = true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
branch: header.branch,
|
|
136
|
+
unborn: header.unborn,
|
|
137
|
+
staged,
|
|
138
|
+
modified,
|
|
139
|
+
untracked,
|
|
140
|
+
ahead: header.ahead,
|
|
141
|
+
behind: header.behind,
|
|
142
|
+
changes,
|
|
143
|
+
truncated,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI` output.
|
|
149
|
+
* One commit per line, fields separated by the unit separator; empty output
|
|
150
|
+
* (unborn repository) yields `[]`.
|
|
151
|
+
*/
|
|
152
|
+
export function parseLogOutput(output: string): readonly GitCommit[] {
|
|
153
|
+
const commits: GitCommit[] = []
|
|
154
|
+
for (const line of output.split('\n')) {
|
|
155
|
+
if (line === '') continue
|
|
156
|
+
const [hash, shortHash, subject, author, dateIso] = line.split(LOG_SEP)
|
|
157
|
+
if (hash === undefined || hash === '') continue
|
|
158
|
+
commits.push({
|
|
159
|
+
hash,
|
|
160
|
+
shortHash: shortHash ?? '',
|
|
161
|
+
subject: subject ?? '',
|
|
162
|
+
author: author ?? '',
|
|
163
|
+
dateIso: dateIso ?? '',
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return commits
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parse `git branch --show-current` output: the branch name, or null when
|
|
171
|
+
* empty (detached HEAD).
|
|
172
|
+
*/
|
|
173
|
+
export function parseBranchOutput(output: string): string | null {
|
|
174
|
+
const trimmed = output.trim()
|
|
175
|
+
return trimmed === '' ? null : trimmed
|
|
176
|
+
}
|