@lijian-ui/dsh-file-manager 0.2.3 → 0.2.4
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 +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27305 -270
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +17 -0
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +42 -3
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -526
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +7 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +49 -0
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +3 -1
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -371
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
package/src/host/git-service.ts
CHANGED
|
@@ -1,395 +1,395 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host git service for the SCM tab: working-tree status (porcelain v1, -z),
|
|
3
|
-
* stage/unstage/discard batches, all scoped to the gated project root and
|
|
4
|
-
* executed through the managed subprocess seam. Parsing is pure and exported
|
|
5
|
-
* for tests; the service only wraps the runner. Discard never touches the
|
|
6
|
-
* staged side (the index is only ever rewritten by stage/unstage), matching
|
|
7
|
-
* the "discard = worktree side" contract.
|
|
8
|
-
* @module dsh-filemgr/host/git-service
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { join, relative } from 'node:path'
|
|
12
|
-
import { realpath } from 'node:fs/promises'
|
|
13
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
-
import type {} from '@deepseek-ai/dsh-subprocess'
|
|
15
|
-
import { subprocessRunner as sharedSubprocessRunner, type GitRunResult, type GitRunner } from './git-runner.ts'
|
|
16
|
-
import type { GitBatchResult, GitChangeRow, GitFileState, GitStatusView, PanelError } from '../core/types.ts'
|
|
17
|
-
import { isPathInside, type WorkspaceGate } from './gate.ts'
|
|
18
|
-
|
|
19
|
-
/** One finished git invocation (shared runner plumbing). */
|
|
20
|
-
export type { GitRunResult, GitRunner } from './git-runner.ts'
|
|
21
|
-
|
|
22
|
-
/** TTL for a positive repo-top-level verdict. */
|
|
23
|
-
const REPO_CACHE_TTL_MS = 60_000
|
|
24
|
-
/** TTL for a negative (null) repo-top-level verdict. */
|
|
25
|
-
const NO_REPO_CACHE_TTL_MS = 30_000
|
|
26
|
-
|
|
27
|
-
/** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
|
|
28
|
-
export function subprocessRunner(ctx: Context): GitRunner {
|
|
29
|
-
return sharedSubprocessRunner(ctx, { failureMode: 'degrade', errorTag: 'dsh-filemgr' })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Map one porcelain letter to the row state (unknown letters stay unknown). */
|
|
33
|
-
export function porcelainState(letter: string): GitFileState {
|
|
34
|
-
switch (letter) {
|
|
35
|
-
case 'A': return 'created'
|
|
36
|
-
case 'M': return 'modified'
|
|
37
|
-
case 'D': return 'deleted'
|
|
38
|
-
case 'R': return 'renamed'
|
|
39
|
-
case 'C': return 'created'
|
|
40
|
-
case 'U': return 'conflicted'
|
|
41
|
-
case '?': return 'untracked'
|
|
42
|
-
default: return 'unknown'
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
|
|
48
|
-
* rows. With -z every entry is NUL-terminated; rename entries carry two paths
|
|
49
|
-
* (old and new). Pure — exported for tests.
|
|
50
|
-
* @param output - raw porcelain v1 -z output.
|
|
51
|
-
* @returns the three change groups.
|
|
52
|
-
*/
|
|
53
|
-
export function parsePorcelain(output: string): {
|
|
54
|
-
staged: GitChangeRow[]
|
|
55
|
-
unstaged: GitChangeRow[]
|
|
56
|
-
untracked: GitChangeRow[]
|
|
57
|
-
} {
|
|
58
|
-
const staged: GitChangeRow[] = []
|
|
59
|
-
const unstaged: GitChangeRow[] = []
|
|
60
|
-
const untracked: GitChangeRow[] = []
|
|
61
|
-
if (output === '') return { staged, unstaged, untracked }
|
|
62
|
-
const fields = output.split('\0')
|
|
63
|
-
for (let i = 0; i < fields.length; i += 1) {
|
|
64
|
-
const field = fields[i]
|
|
65
|
-
if (field === '') continue
|
|
66
|
-
const x = field[0] ?? ' '
|
|
67
|
-
const y = field[1] ?? ' '
|
|
68
|
-
const path = field.slice(3)
|
|
69
|
-
if (x === '?' && y === '?') {
|
|
70
|
-
untracked.push({ path, state: 'untracked', staged: false })
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (x === 'R' || x === 'C') {
|
|
74
|
-
// -z rename entries: XY old\0new — the path field holds the old path.
|
|
75
|
-
const oldPath = path
|
|
76
|
-
const newPath = fields[i + 1] ?? oldPath
|
|
77
|
-
i += 1
|
|
78
|
-
staged.push({ path: newPath, oldPath, state: porcelainState(x), staged: true })
|
|
79
|
-
if (y !== ' ') {
|
|
80
|
-
unstaged.push({ path: newPath, oldPath, state: porcelainState(y), staged: false })
|
|
81
|
-
}
|
|
82
|
-
continue
|
|
83
|
-
}
|
|
84
|
-
if (x !== ' ') {
|
|
85
|
-
staged.push({ path, state: porcelainState(x), staged: true })
|
|
86
|
-
}
|
|
87
|
-
if (y !== ' ') {
|
|
88
|
-
unstaged.push({ path, state: porcelainState(y), staged: false })
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { staged, unstaged, untracked }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Parse the porcelain row set into the status view shape. */
|
|
95
|
-
export function parseStatusView(root: string, branch: string, output: string): GitStatusView {
|
|
96
|
-
const { staged, unstaged, untracked } = parsePorcelain(output)
|
|
97
|
-
return { root, branch, staged, unstaged, untracked }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The not-a-repository verdict for status reads. */
|
|
101
|
-
const NO_REPO: PanelError = { code: 'git-unavailable', message: 'not a git repository' }
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Workspace-scoped git operations. Gated methods pass the gate, resolve the
|
|
105
|
-
* repository root, and reject non-repositories with a stable error; the
|
|
106
|
-
* `Canonical` variants trust an already-gated canonical root (the SSE poll)
|
|
107
|
-
* and skip the gate.
|
|
108
|
-
* @param runner - the spawn seam.
|
|
109
|
-
* @param gate - workspace-membership gate.
|
|
110
|
-
* @param fsDelete - delete seam for untracked discard (host: FsService.delete).
|
|
111
|
-
*/
|
|
112
|
-
export class GitService {
|
|
113
|
-
constructor(
|
|
114
|
-
private readonly runner: GitRunner,
|
|
115
|
-
private readonly gate: WorkspaceGate,
|
|
116
|
-
private readonly fsDelete: (root: string, rel: string) => Promise<{ ok: true } | PanelError>,
|
|
117
|
-
) {}
|
|
118
|
-
|
|
119
|
-
/** Cached one-shot git binary probe; never re-probes after the first call. */
|
|
120
|
-
private availablePromise: Promise<boolean> | undefined
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Cached repo-top-level resolution per canonical workspace, with a TTL so
|
|
124
|
-
* running `git init` (positive self-heal) or deleting `.git` (negative
|
|
125
|
-
* self-heal) is discovered by a later probe. Positive verdicts live 60s,
|
|
126
|
-
* negative (null) verdicts 30s; exitCode 127 is never cached because it
|
|
127
|
-
* means spawn/run failed rather than "not a repository".
|
|
128
|
-
*/
|
|
129
|
-
private readonly repoCache = new Map<string, { value: Promise<string | null>; expiresAt: number }>()
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Probe the git binary once (git --version) and cache the verdict for the
|
|
133
|
-
* service lifetime. A machine without git then degrades every operation to
|
|
134
|
-
* the stable "not a git repository" state after a single failed spawn,
|
|
135
|
-
* instead of re-spawning ENOENT on every poll tick. The cache stays false
|
|
136
|
-
* even if git is installed later; the host restart picks it up.
|
|
137
|
-
*/
|
|
138
|
-
gitAvailable(): Promise<boolean> {
|
|
139
|
-
if (this.availablePromise === undefined) {
|
|
140
|
-
this.availablePromise = this.runner
|
|
141
|
-
.run(['--version'], '/')
|
|
142
|
-
.then((result) => result.exitCode === 0)
|
|
143
|
-
.catch(() => false)
|
|
144
|
-
}
|
|
145
|
-
return this.availablePromise
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Resolve the repo top-level for one canonical root. Verdicts are cached
|
|
150
|
-
* with a TTL: a positive repo path for 60s, a negative null for 30s. After
|
|
151
|
-
* expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
|
|
152
|
-
* created or removed while the host is running is picked up later. An
|
|
153
|
-
* exitCode 127 means the spawn/run itself failed; it returns null but is
|
|
154
|
-
* deliberately not cached so the next call retries. Any other failure is
|
|
155
|
-
* cached as a negative verdict for its TTL.
|
|
156
|
-
*/
|
|
157
|
-
private repoOf(root: string): Promise<string | null> {
|
|
158
|
-
const now = Date.now()
|
|
159
|
-
const cached = this.repoCache.get(root)
|
|
160
|
-
if (cached !== undefined && cached.expiresAt > now) return cached.value
|
|
161
|
-
// Infinity while the probe is in flight: concurrent callers share the
|
|
162
|
-
// same promise, and the real TTL is stamped once the verdict settles.
|
|
163
|
-
const entry: { value: Promise<string | null>; expiresAt: number } = {
|
|
164
|
-
value: Promise.resolve(null),
|
|
165
|
-
expiresAt: Number.POSITIVE_INFINITY,
|
|
166
|
-
}
|
|
167
|
-
entry.value = this.run(['rev-parse', '--show-toplevel'], root)
|
|
168
|
-
.then((result) => {
|
|
169
|
-
if (result.exitCode === 127) {
|
|
170
|
-
// Spawn/run failure is not a repo verdict: leave nothing cached.
|
|
171
|
-
if (this.repoCache.get(root) === entry) this.repoCache.delete(root)
|
|
172
|
-
return null
|
|
173
|
-
}
|
|
174
|
-
if (result.exitCode !== 0) {
|
|
175
|
-
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
176
|
-
return null
|
|
177
|
-
}
|
|
178
|
-
const repo = result.stdout.trim()
|
|
179
|
-
const found = repo !== '' && isPathInside(repo, root) ? repo : null
|
|
180
|
-
entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS)
|
|
181
|
-
return found
|
|
182
|
-
})
|
|
183
|
-
.catch(() => {
|
|
184
|
-
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
185
|
-
return null
|
|
186
|
-
})
|
|
187
|
-
this.repoCache.set(root, entry)
|
|
188
|
-
return entry.value
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Whether an already-gated canonical root is a git repository. Skips the
|
|
193
|
-
* workspace gate so the SSE poll does not double-gate every 2s tick; the
|
|
194
|
-
* underlying repoOf cache keeps rev-parse probes at TTL cadence.
|
|
195
|
-
*/
|
|
196
|
-
isRepositoryCanonical(canonicalRoot: string): Promise<boolean> {
|
|
197
|
-
return this.repoOf(canonicalRoot).then((repo) => repo !== null)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Whether a workspace root is a git repository. Gates the root first (POST
|
|
202
|
-
* route entry point); the SSE poll should use `isRepositoryCanonical`.
|
|
203
|
-
*/
|
|
204
|
-
async isRepository(root: string): Promise<boolean> {
|
|
205
|
-
const gated = await this.gate(root)
|
|
206
|
-
if (!gated.ok) return false
|
|
207
|
-
return await this.isRepositoryCanonical(gated.canonical)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Resolve the gated canonical root and the repository top-level. */
|
|
211
|
-
private async repo(root: string): Promise<{ ok: true; root: string; repo: string } | { ok: false; error: PanelError }> {
|
|
212
|
-
const gated = await this.gate(root)
|
|
213
|
-
if (!gated.ok) return { ok: false, error: gated.error }
|
|
214
|
-
const repo = await this.repoOf(gated.canonical)
|
|
215
|
-
if (repo === null) return { ok: false, error: NO_REPO }
|
|
216
|
-
return { ok: true, root: gated.canonical, repo }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Run one git invocation and classify failures. */
|
|
220
|
-
private async run(argv: readonly string[], cwd: string): Promise<GitRunResult> {
|
|
221
|
-
return this.runner.run(argv, cwd)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** The repo status view; null when the root is not a repository. */
|
|
225
|
-
async status(root: string): Promise<GitStatusView | null | PanelError> {
|
|
226
|
-
// A missing git binary answers before any spawn: the probe runs once per
|
|
227
|
-
// service lifetime, so a git-less machine never re-spawns ENOENT here.
|
|
228
|
-
if (!(await this.gitAvailable())) return null
|
|
229
|
-
const repo = await this.repo(root)
|
|
230
|
-
if (!repo.ok) return repo.error.code === 'git-unavailable' ? null : repo.error
|
|
231
|
-
return this.statusAt(repo.root, repo.repo)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* The repo status view for an already-gated canonical root; null when it is
|
|
236
|
-
* not a repository. Skips the workspace gate (SSE subscribers were gated at
|
|
237
|
-
* connect) and reuses the same repoOf cache + status parsing as `status`.
|
|
238
|
-
*/
|
|
239
|
-
async statusCanonical(canonicalRoot: string): Promise<GitStatusView | null> {
|
|
240
|
-
const repo = await this.repoOf(canonicalRoot)
|
|
241
|
-
if (repo === null) return null
|
|
242
|
-
return this.statusAt(canonicalRoot, repo)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Run branch + porcelain status for one resolved repo and parse the view. */
|
|
246
|
-
private async statusAt(root: string, repo: string): Promise<GitStatusView> {
|
|
247
|
-
const [branchResult, statusResult] = await Promise.all([
|
|
248
|
-
this.run(['rev-parse', '--abbrev-ref', 'HEAD'], repo),
|
|
249
|
-
this.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], repo),
|
|
250
|
-
])
|
|
251
|
-
const branch = branchResult.stdout.trim() === 'HEAD' ? '' : branchResult.stdout.trim()
|
|
252
|
-
return parseStatusView(root, branch, statusResult.stdout)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** The repo root for the watch layer (null when not a repository). */
|
|
256
|
-
async repoRoot(root: string): Promise<string | null> {
|
|
257
|
-
const repo = await this.repo(root)
|
|
258
|
-
return repo.ok ? repo.repo : null
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* The unified diff of one path ('' when there is no diff to show). Staged
|
|
263
|
-
* paths diff the index against HEAD (`--cached`); unstaged paths diff the
|
|
264
|
-
* worktree against the index. Untracked paths have no index/HEAD entry, so
|
|
265
|
-
* they diff against /dev/null (the canonical new-file shape); its exit code
|
|
266
|
-
* is 1 — differences exist — which is a success here, not a failure.
|
|
267
|
-
*/
|
|
268
|
-
async diff(root: string, path: string, staged: boolean): Promise<{ content: string } | PanelError> {
|
|
269
|
-
const repo = await this.repo(root)
|
|
270
|
-
if (!repo.ok) return repo.error
|
|
271
|
-
const abs = join(repo.repo, path)
|
|
272
|
-
if (!isPathInside(repo.repo, abs)) return { code: 'path-outside-root', message: 'path outside the repository' }
|
|
273
|
-
const rel = relative(repo.repo, abs)
|
|
274
|
-
const tracked = await this.run(['ls-files', '--error-unmatch', '--', rel], repo.repo)
|
|
275
|
-
const result = tracked.exitCode !== 0
|
|
276
|
-
? await this.run(['diff', '--no-index', '--', '/dev/null', rel], repo.repo)
|
|
277
|
-
: staged
|
|
278
|
-
? await this.run(['diff', '--cached', '--', rel], repo.repo)
|
|
279
|
-
: await this.run(['diff', '--', rel], repo.repo)
|
|
280
|
-
// --no-index reports exit 1 when differences exist; a plain diff exits 0.
|
|
281
|
-
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
282
|
-
return { code: 'git-failed', message: 'git diff failed' }
|
|
283
|
-
}
|
|
284
|
-
return { content: result.stdout }
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Verify paths stay inside the repo root (defense in depth). */
|
|
288
|
-
private pathsInside(repo: string, paths: string[]): string[] {
|
|
289
|
-
const abs = paths.map((p) => join(repo, p))
|
|
290
|
-
return abs.filter((p) => isPathInside(repo, p)).map((p) => p)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Stage paths (git add). Batch result reflects the post-op status. */
|
|
294
|
-
async stage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
295
|
-
return this.batch(root, paths, async (repo, inside) => {
|
|
296
|
-
const result = await this.run(['add', '--', ...inside], repo)
|
|
297
|
-
return result.exitCode === 0
|
|
298
|
-
})
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Unstage paths (git restore --staged). */
|
|
302
|
-
async unstage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
303
|
-
return this.batch(root, paths, async (repo, inside) => {
|
|
304
|
-
const result = await this.run(['restore', '--staged', '--', ...inside], repo)
|
|
305
|
-
return result.exitCode === 0
|
|
306
|
-
})
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Discard paths (worktree side only). Tracked paths are restored from the
|
|
311
|
-
* index; untracked paths are deleted through the fs seam. The batch reports
|
|
312
|
-
* applied/failed per path.
|
|
313
|
-
*/
|
|
314
|
-
async discard(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
315
|
-
const repo = await this.repo(root)
|
|
316
|
-
if (!repo.ok) return repo.error
|
|
317
|
-
const inside = this.pathsInside(repo.repo, paths)
|
|
318
|
-
const applied: string[] = []
|
|
319
|
-
const failed: string[] = []
|
|
320
|
-
// Membership is checked on the ABSOLUTE path (pathsInside resolves); the
|
|
321
|
-
// git commands below run with the repo-relative path (cwd = repo).
|
|
322
|
-
const eligible: string[] = []
|
|
323
|
-
for (const p of paths) {
|
|
324
|
-
if (inside.includes(join(repo.repo, p))) eligible.push(p)
|
|
325
|
-
else failed.push(p)
|
|
326
|
-
}
|
|
327
|
-
// One ls-files classifies every eligible path (its output IS the tracked
|
|
328
|
-
// set) instead of one --error-unmatch spawn per path.
|
|
329
|
-
const trackedSet = new Set<string>()
|
|
330
|
-
if (eligible.length > 0) {
|
|
331
|
-
const listed = await this.run(['ls-files', '-z', '--', ...eligible.map(p => ':(literal)' + p)], repo.repo)
|
|
332
|
-
for (const entry of listed.stdout.split('\0')) if (entry !== '') trackedSet.add(entry)
|
|
333
|
-
}
|
|
334
|
-
const tracked = eligible.filter(p => trackedSet.has(p))
|
|
335
|
-
const untracked = eligible.filter(p => !trackedSet.has(p))
|
|
336
|
-
// Tracked paths restore in one spawn; a batch failure falls back to
|
|
337
|
-
// per-path restores so the applied/failed split stays exact.
|
|
338
|
-
if (tracked.length > 0) {
|
|
339
|
-
const restored = await this.run(['restore', '--worktree', '--', ...tracked.map(p => ':(literal)' + p)], repo.repo)
|
|
340
|
-
if (restored.exitCode === 0) {
|
|
341
|
-
applied.push(...tracked)
|
|
342
|
-
} else {
|
|
343
|
-
for (const p of tracked) {
|
|
344
|
-
const single = await this.run(['restore', '--worktree', '--', ':(literal)' + p], repo.repo)
|
|
345
|
-
if (single.exitCode === 0) applied.push(p)
|
|
346
|
-
else failed.push(p)
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
for (const p of untracked) {
|
|
351
|
-
// Untracked paths are not restored by git restore; delete them directly.
|
|
352
|
-
// A symlink entry pointing outside the repo must not be deleted — the
|
|
353
|
-
// fs delete would follow the link away. Realpath-check before deleting.
|
|
354
|
-
try {
|
|
355
|
-
const real = await realpath(join(repo.repo, p))
|
|
356
|
-
if (!isPathInside(repo.repo, real)) {
|
|
357
|
-
failed.push(p)
|
|
358
|
-
continue
|
|
359
|
-
}
|
|
360
|
-
} catch {
|
|
361
|
-
// The path does not exist on disk (ENOENT): nothing to escape.
|
|
362
|
-
}
|
|
363
|
-
// The fs seam addresses the project ROOT (which may be a subdir of
|
|
364
|
-
// the repo); derive the root-relative path from the absolute one.
|
|
365
|
-
const rel = relative(repo.root, join(repo.repo, p))
|
|
366
|
-
// Untracked files that lie outside the session root cannot be deleted
|
|
367
|
-
// through the fs seam; refuse rather than delete a look-alike path.
|
|
368
|
-
if (rel === '..' || rel.startsWith('../')) {
|
|
369
|
-
failed.push(p)
|
|
370
|
-
continue
|
|
371
|
-
}
|
|
372
|
-
const deleted = await this.fsDelete(repo.root, rel)
|
|
373
|
-
if ('ok' in deleted && deleted.ok) applied.push(p)
|
|
374
|
-
else failed.push(p)
|
|
375
|
-
}
|
|
376
|
-
return { applied, failed }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
|
|
380
|
-
private async batch(
|
|
381
|
-
root: string,
|
|
382
|
-
paths: string[],
|
|
383
|
-
op: (repo: string, inside: string[]) => Promise<boolean>,
|
|
384
|
-
): Promise<GitBatchResult | PanelError> {
|
|
385
|
-
const repo = await this.repo(root)
|
|
386
|
-
if (!repo.ok) return repo.error
|
|
387
|
-
const inside = this.pathsInside(repo.repo, paths)
|
|
388
|
-
const ok = inside.length > 0 ? await op(repo.repo, inside) : true
|
|
389
|
-
if (!ok) return { code: 'git-failed', message: 'git operation failed' }
|
|
390
|
-
// Report the REQUESTED (repo-relative) spellings, not the resolved absolutes.
|
|
391
|
-
const applied = ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : []
|
|
392
|
-
const failed = paths.filter((p) => !inside.includes(join(repo.repo, p)))
|
|
393
|
-
return { applied, failed }
|
|
394
|
-
}
|
|
395
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Host git service for the SCM tab: working-tree status (porcelain v1, -z),
|
|
3
|
+
* stage/unstage/discard batches, all scoped to the gated project root and
|
|
4
|
+
* executed through the managed subprocess seam. Parsing is pure and exported
|
|
5
|
+
* for tests; the service only wraps the runner. Discard never touches the
|
|
6
|
+
* staged side (the index is only ever rewritten by stage/unstage), matching
|
|
7
|
+
* the "discard = worktree side" contract.
|
|
8
|
+
* @module dsh-filemgr/host/git-service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join, relative } from 'node:path'
|
|
12
|
+
import { realpath } from 'node:fs/promises'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-subprocess'
|
|
15
|
+
import { subprocessRunner as sharedSubprocessRunner, type GitRunResult, type GitRunner } from './git-runner.ts'
|
|
16
|
+
import type { GitBatchResult, GitChangeRow, GitFileState, GitStatusView, PanelError } from '../core/types.ts'
|
|
17
|
+
import { isPathInside, type WorkspaceGate } from './gate.ts'
|
|
18
|
+
|
|
19
|
+
/** One finished git invocation (shared runner plumbing). */
|
|
20
|
+
export type { GitRunResult, GitRunner } from './git-runner.ts'
|
|
21
|
+
|
|
22
|
+
/** TTL for a positive repo-top-level verdict. */
|
|
23
|
+
const REPO_CACHE_TTL_MS = 60_000
|
|
24
|
+
/** TTL for a negative (null) repo-top-level verdict. */
|
|
25
|
+
const NO_REPO_CACHE_TTL_MS = 30_000
|
|
26
|
+
|
|
27
|
+
/** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
|
|
28
|
+
export function subprocessRunner(ctx: Context): GitRunner {
|
|
29
|
+
return sharedSubprocessRunner(ctx, { failureMode: 'degrade', errorTag: 'dsh-filemgr' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Map one porcelain letter to the row state (unknown letters stay unknown). */
|
|
33
|
+
export function porcelainState(letter: string): GitFileState {
|
|
34
|
+
switch (letter) {
|
|
35
|
+
case 'A': return 'created'
|
|
36
|
+
case 'M': return 'modified'
|
|
37
|
+
case 'D': return 'deleted'
|
|
38
|
+
case 'R': return 'renamed'
|
|
39
|
+
case 'C': return 'created'
|
|
40
|
+
case 'U': return 'conflicted'
|
|
41
|
+
case '?': return 'untracked'
|
|
42
|
+
default: return 'unknown'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
|
|
48
|
+
* rows. With -z every entry is NUL-terminated; rename entries carry two paths
|
|
49
|
+
* (old and new). Pure — exported for tests.
|
|
50
|
+
* @param output - raw porcelain v1 -z output.
|
|
51
|
+
* @returns the three change groups.
|
|
52
|
+
*/
|
|
53
|
+
export function parsePorcelain(output: string): {
|
|
54
|
+
staged: GitChangeRow[]
|
|
55
|
+
unstaged: GitChangeRow[]
|
|
56
|
+
untracked: GitChangeRow[]
|
|
57
|
+
} {
|
|
58
|
+
const staged: GitChangeRow[] = []
|
|
59
|
+
const unstaged: GitChangeRow[] = []
|
|
60
|
+
const untracked: GitChangeRow[] = []
|
|
61
|
+
if (output === '') return { staged, unstaged, untracked }
|
|
62
|
+
const fields = output.split('\0')
|
|
63
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
64
|
+
const field = fields[i]
|
|
65
|
+
if (field === '') continue
|
|
66
|
+
const x = field[0] ?? ' '
|
|
67
|
+
const y = field[1] ?? ' '
|
|
68
|
+
const path = field.slice(3)
|
|
69
|
+
if (x === '?' && y === '?') {
|
|
70
|
+
untracked.push({ path, state: 'untracked', staged: false })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (x === 'R' || x === 'C') {
|
|
74
|
+
// -z rename entries: XY old\0new — the path field holds the old path.
|
|
75
|
+
const oldPath = path
|
|
76
|
+
const newPath = fields[i + 1] ?? oldPath
|
|
77
|
+
i += 1
|
|
78
|
+
staged.push({ path: newPath, oldPath, state: porcelainState(x), staged: true })
|
|
79
|
+
if (y !== ' ') {
|
|
80
|
+
unstaged.push({ path: newPath, oldPath, state: porcelainState(y), staged: false })
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (x !== ' ') {
|
|
85
|
+
staged.push({ path, state: porcelainState(x), staged: true })
|
|
86
|
+
}
|
|
87
|
+
if (y !== ' ') {
|
|
88
|
+
unstaged.push({ path, state: porcelainState(y), staged: false })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { staged, unstaged, untracked }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse the porcelain row set into the status view shape. */
|
|
95
|
+
export function parseStatusView(root: string, branch: string, output: string): GitStatusView {
|
|
96
|
+
const { staged, unstaged, untracked } = parsePorcelain(output)
|
|
97
|
+
return { root, branch, staged, unstaged, untracked }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The not-a-repository verdict for status reads. */
|
|
101
|
+
const NO_REPO: PanelError = { code: 'git-unavailable', message: 'not a git repository' }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Workspace-scoped git operations. Gated methods pass the gate, resolve the
|
|
105
|
+
* repository root, and reject non-repositories with a stable error; the
|
|
106
|
+
* `Canonical` variants trust an already-gated canonical root (the SSE poll)
|
|
107
|
+
* and skip the gate.
|
|
108
|
+
* @param runner - the spawn seam.
|
|
109
|
+
* @param gate - workspace-membership gate.
|
|
110
|
+
* @param fsDelete - delete seam for untracked discard (host: FsService.delete).
|
|
111
|
+
*/
|
|
112
|
+
export class GitService {
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly runner: GitRunner,
|
|
115
|
+
private readonly gate: WorkspaceGate,
|
|
116
|
+
private readonly fsDelete: (root: string, rel: string) => Promise<{ ok: true } | PanelError>,
|
|
117
|
+
) {}
|
|
118
|
+
|
|
119
|
+
/** Cached one-shot git binary probe; never re-probes after the first call. */
|
|
120
|
+
private availablePromise: Promise<boolean> | undefined
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cached repo-top-level resolution per canonical workspace, with a TTL so
|
|
124
|
+
* running `git init` (positive self-heal) or deleting `.git` (negative
|
|
125
|
+
* self-heal) is discovered by a later probe. Positive verdicts live 60s,
|
|
126
|
+
* negative (null) verdicts 30s; exitCode 127 is never cached because it
|
|
127
|
+
* means spawn/run failed rather than "not a repository".
|
|
128
|
+
*/
|
|
129
|
+
private readonly repoCache = new Map<string, { value: Promise<string | null>; expiresAt: number }>()
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Probe the git binary once (git --version) and cache the verdict for the
|
|
133
|
+
* service lifetime. A machine without git then degrades every operation to
|
|
134
|
+
* the stable "not a git repository" state after a single failed spawn,
|
|
135
|
+
* instead of re-spawning ENOENT on every poll tick. The cache stays false
|
|
136
|
+
* even if git is installed later; the host restart picks it up.
|
|
137
|
+
*/
|
|
138
|
+
gitAvailable(): Promise<boolean> {
|
|
139
|
+
if (this.availablePromise === undefined) {
|
|
140
|
+
this.availablePromise = this.runner
|
|
141
|
+
.run(['--version'], '/')
|
|
142
|
+
.then((result) => result.exitCode === 0)
|
|
143
|
+
.catch(() => false)
|
|
144
|
+
}
|
|
145
|
+
return this.availablePromise
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the repo top-level for one canonical root. Verdicts are cached
|
|
150
|
+
* with a TTL: a positive repo path for 60s, a negative null for 30s. After
|
|
151
|
+
* expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
|
|
152
|
+
* created or removed while the host is running is picked up later. An
|
|
153
|
+
* exitCode 127 means the spawn/run itself failed; it returns null but is
|
|
154
|
+
* deliberately not cached so the next call retries. Any other failure is
|
|
155
|
+
* cached as a negative verdict for its TTL.
|
|
156
|
+
*/
|
|
157
|
+
private repoOf(root: string): Promise<string | null> {
|
|
158
|
+
const now = Date.now()
|
|
159
|
+
const cached = this.repoCache.get(root)
|
|
160
|
+
if (cached !== undefined && cached.expiresAt > now) return cached.value
|
|
161
|
+
// Infinity while the probe is in flight: concurrent callers share the
|
|
162
|
+
// same promise, and the real TTL is stamped once the verdict settles.
|
|
163
|
+
const entry: { value: Promise<string | null>; expiresAt: number } = {
|
|
164
|
+
value: Promise.resolve(null),
|
|
165
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
166
|
+
}
|
|
167
|
+
entry.value = this.run(['rev-parse', '--show-toplevel'], root)
|
|
168
|
+
.then((result) => {
|
|
169
|
+
if (result.exitCode === 127) {
|
|
170
|
+
// Spawn/run failure is not a repo verdict: leave nothing cached.
|
|
171
|
+
if (this.repoCache.get(root) === entry) this.repoCache.delete(root)
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
if (result.exitCode !== 0) {
|
|
175
|
+
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
const repo = result.stdout.trim()
|
|
179
|
+
const found = repo !== '' && isPathInside(repo, root) ? repo : null
|
|
180
|
+
entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS)
|
|
181
|
+
return found
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {
|
|
184
|
+
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
185
|
+
return null
|
|
186
|
+
})
|
|
187
|
+
this.repoCache.set(root, entry)
|
|
188
|
+
return entry.value
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether an already-gated canonical root is a git repository. Skips the
|
|
193
|
+
* workspace gate so the SSE poll does not double-gate every 2s tick; the
|
|
194
|
+
* underlying repoOf cache keeps rev-parse probes at TTL cadence.
|
|
195
|
+
*/
|
|
196
|
+
isRepositoryCanonical(canonicalRoot: string): Promise<boolean> {
|
|
197
|
+
return this.repoOf(canonicalRoot).then((repo) => repo !== null)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a workspace root is a git repository. Gates the root first (POST
|
|
202
|
+
* route entry point); the SSE poll should use `isRepositoryCanonical`.
|
|
203
|
+
*/
|
|
204
|
+
async isRepository(root: string): Promise<boolean> {
|
|
205
|
+
const gated = await this.gate(root)
|
|
206
|
+
if (!gated.ok) return false
|
|
207
|
+
return await this.isRepositoryCanonical(gated.canonical)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Resolve the gated canonical root and the repository top-level. */
|
|
211
|
+
private async repo(root: string): Promise<{ ok: true; root: string; repo: string } | { ok: false; error: PanelError }> {
|
|
212
|
+
const gated = await this.gate(root)
|
|
213
|
+
if (!gated.ok) return { ok: false, error: gated.error }
|
|
214
|
+
const repo = await this.repoOf(gated.canonical)
|
|
215
|
+
if (repo === null) return { ok: false, error: NO_REPO }
|
|
216
|
+
return { ok: true, root: gated.canonical, repo }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run one git invocation and classify failures. */
|
|
220
|
+
private async run(argv: readonly string[], cwd: string): Promise<GitRunResult> {
|
|
221
|
+
return this.runner.run(argv, cwd)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The repo status view; null when the root is not a repository. */
|
|
225
|
+
async status(root: string): Promise<GitStatusView | null | PanelError> {
|
|
226
|
+
// A missing git binary answers before any spawn: the probe runs once per
|
|
227
|
+
// service lifetime, so a git-less machine never re-spawns ENOENT here.
|
|
228
|
+
if (!(await this.gitAvailable())) return null
|
|
229
|
+
const repo = await this.repo(root)
|
|
230
|
+
if (!repo.ok) return repo.error.code === 'git-unavailable' ? null : repo.error
|
|
231
|
+
return this.statusAt(repo.root, repo.repo)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The repo status view for an already-gated canonical root; null when it is
|
|
236
|
+
* not a repository. Skips the workspace gate (SSE subscribers were gated at
|
|
237
|
+
* connect) and reuses the same repoOf cache + status parsing as `status`.
|
|
238
|
+
*/
|
|
239
|
+
async statusCanonical(canonicalRoot: string): Promise<GitStatusView | null> {
|
|
240
|
+
const repo = await this.repoOf(canonicalRoot)
|
|
241
|
+
if (repo === null) return null
|
|
242
|
+
return this.statusAt(canonicalRoot, repo)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Run branch + porcelain status for one resolved repo and parse the view. */
|
|
246
|
+
private async statusAt(root: string, repo: string): Promise<GitStatusView> {
|
|
247
|
+
const [branchResult, statusResult] = await Promise.all([
|
|
248
|
+
this.run(['rev-parse', '--abbrev-ref', 'HEAD'], repo),
|
|
249
|
+
this.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], repo),
|
|
250
|
+
])
|
|
251
|
+
const branch = branchResult.stdout.trim() === 'HEAD' ? '' : branchResult.stdout.trim()
|
|
252
|
+
return parseStatusView(root, branch, statusResult.stdout)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The repo root for the watch layer (null when not a repository). */
|
|
256
|
+
async repoRoot(root: string): Promise<string | null> {
|
|
257
|
+
const repo = await this.repo(root)
|
|
258
|
+
return repo.ok ? repo.repo : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The unified diff of one path ('' when there is no diff to show). Staged
|
|
263
|
+
* paths diff the index against HEAD (`--cached`); unstaged paths diff the
|
|
264
|
+
* worktree against the index. Untracked paths have no index/HEAD entry, so
|
|
265
|
+
* they diff against /dev/null (the canonical new-file shape); its exit code
|
|
266
|
+
* is 1 — differences exist — which is a success here, not a failure.
|
|
267
|
+
*/
|
|
268
|
+
async diff(root: string, path: string, staged: boolean): Promise<{ content: string } | PanelError> {
|
|
269
|
+
const repo = await this.repo(root)
|
|
270
|
+
if (!repo.ok) return repo.error
|
|
271
|
+
const abs = join(repo.repo, path)
|
|
272
|
+
if (!isPathInside(repo.repo, abs)) return { code: 'path-outside-root', message: 'path outside the repository' }
|
|
273
|
+
const rel = relative(repo.repo, abs)
|
|
274
|
+
const tracked = await this.run(['ls-files', '--error-unmatch', '--', rel], repo.repo)
|
|
275
|
+
const result = tracked.exitCode !== 0
|
|
276
|
+
? await this.run(['diff', '--no-index', '--', '/dev/null', rel], repo.repo)
|
|
277
|
+
: staged
|
|
278
|
+
? await this.run(['diff', '--cached', '--', rel], repo.repo)
|
|
279
|
+
: await this.run(['diff', '--', rel], repo.repo)
|
|
280
|
+
// --no-index reports exit 1 when differences exist; a plain diff exits 0.
|
|
281
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
282
|
+
return { code: 'git-failed', message: 'git diff failed' }
|
|
283
|
+
}
|
|
284
|
+
return { content: result.stdout }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Verify paths stay inside the repo root (defense in depth). */
|
|
288
|
+
private pathsInside(repo: string, paths: string[]): string[] {
|
|
289
|
+
const abs = paths.map((p) => join(repo, p))
|
|
290
|
+
return abs.filter((p) => isPathInside(repo, p)).map((p) => p)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Stage paths (git add). Batch result reflects the post-op status. */
|
|
294
|
+
async stage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
295
|
+
return this.batch(root, paths, async (repo, inside) => {
|
|
296
|
+
const result = await this.run(['add', '--', ...inside], repo)
|
|
297
|
+
return result.exitCode === 0
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Unstage paths (git restore --staged). */
|
|
302
|
+
async unstage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
303
|
+
return this.batch(root, paths, async (repo, inside) => {
|
|
304
|
+
const result = await this.run(['restore', '--staged', '--', ...inside], repo)
|
|
305
|
+
return result.exitCode === 0
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Discard paths (worktree side only). Tracked paths are restored from the
|
|
311
|
+
* index; untracked paths are deleted through the fs seam. The batch reports
|
|
312
|
+
* applied/failed per path.
|
|
313
|
+
*/
|
|
314
|
+
async discard(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
315
|
+
const repo = await this.repo(root)
|
|
316
|
+
if (!repo.ok) return repo.error
|
|
317
|
+
const inside = this.pathsInside(repo.repo, paths)
|
|
318
|
+
const applied: string[] = []
|
|
319
|
+
const failed: string[] = []
|
|
320
|
+
// Membership is checked on the ABSOLUTE path (pathsInside resolves); the
|
|
321
|
+
// git commands below run with the repo-relative path (cwd = repo).
|
|
322
|
+
const eligible: string[] = []
|
|
323
|
+
for (const p of paths) {
|
|
324
|
+
if (inside.includes(join(repo.repo, p))) eligible.push(p)
|
|
325
|
+
else failed.push(p)
|
|
326
|
+
}
|
|
327
|
+
// One ls-files classifies every eligible path (its output IS the tracked
|
|
328
|
+
// set) instead of one --error-unmatch spawn per path.
|
|
329
|
+
const trackedSet = new Set<string>()
|
|
330
|
+
if (eligible.length > 0) {
|
|
331
|
+
const listed = await this.run(['ls-files', '-z', '--', ...eligible.map(p => ':(literal)' + p)], repo.repo)
|
|
332
|
+
for (const entry of listed.stdout.split('\0')) if (entry !== '') trackedSet.add(entry)
|
|
333
|
+
}
|
|
334
|
+
const tracked = eligible.filter(p => trackedSet.has(p))
|
|
335
|
+
const untracked = eligible.filter(p => !trackedSet.has(p))
|
|
336
|
+
// Tracked paths restore in one spawn; a batch failure falls back to
|
|
337
|
+
// per-path restores so the applied/failed split stays exact.
|
|
338
|
+
if (tracked.length > 0) {
|
|
339
|
+
const restored = await this.run(['restore', '--worktree', '--', ...tracked.map(p => ':(literal)' + p)], repo.repo)
|
|
340
|
+
if (restored.exitCode === 0) {
|
|
341
|
+
applied.push(...tracked)
|
|
342
|
+
} else {
|
|
343
|
+
for (const p of tracked) {
|
|
344
|
+
const single = await this.run(['restore', '--worktree', '--', ':(literal)' + p], repo.repo)
|
|
345
|
+
if (single.exitCode === 0) applied.push(p)
|
|
346
|
+
else failed.push(p)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const p of untracked) {
|
|
351
|
+
// Untracked paths are not restored by git restore; delete them directly.
|
|
352
|
+
// A symlink entry pointing outside the repo must not be deleted — the
|
|
353
|
+
// fs delete would follow the link away. Realpath-check before deleting.
|
|
354
|
+
try {
|
|
355
|
+
const real = await realpath(join(repo.repo, p))
|
|
356
|
+
if (!isPathInside(repo.repo, real)) {
|
|
357
|
+
failed.push(p)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// The path does not exist on disk (ENOENT): nothing to escape.
|
|
362
|
+
}
|
|
363
|
+
// The fs seam addresses the project ROOT (which may be a subdir of
|
|
364
|
+
// the repo); derive the root-relative path from the absolute one.
|
|
365
|
+
const rel = relative(repo.root, join(repo.repo, p))
|
|
366
|
+
// Untracked files that lie outside the session root cannot be deleted
|
|
367
|
+
// through the fs seam; refuse rather than delete a look-alike path.
|
|
368
|
+
if (rel === '..' || rel.startsWith('../')) {
|
|
369
|
+
failed.push(p)
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const deleted = await this.fsDelete(repo.root, rel)
|
|
373
|
+
if ('ok' in deleted && deleted.ok) applied.push(p)
|
|
374
|
+
else failed.push(p)
|
|
375
|
+
}
|
|
376
|
+
return { applied, failed }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
|
|
380
|
+
private async batch(
|
|
381
|
+
root: string,
|
|
382
|
+
paths: string[],
|
|
383
|
+
op: (repo: string, inside: string[]) => Promise<boolean>,
|
|
384
|
+
): Promise<GitBatchResult | PanelError> {
|
|
385
|
+
const repo = await this.repo(root)
|
|
386
|
+
if (!repo.ok) return repo.error
|
|
387
|
+
const inside = this.pathsInside(repo.repo, paths)
|
|
388
|
+
const ok = inside.length > 0 ? await op(repo.repo, inside) : true
|
|
389
|
+
if (!ok) return { code: 'git-failed', message: 'git operation failed' }
|
|
390
|
+
// Report the REQUESTED (repo-relative) spellings, not the resolved absolutes.
|
|
391
|
+
const applied = ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : []
|
|
392
|
+
const failed = paths.filter((p) => !inside.includes(join(repo.repo, p)))
|
|
393
|
+
return { applied, failed }
|
|
394
|
+
}
|
|
395
|
+
}
|