@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6
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/CHANGELOG.md +42 -0
- package/CHANGELOG_EN.md +42 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1465 -166
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +76 -9
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unified diff, parsed so that a SUBSET of it can be emitted back as a patch
|
|
3
|
+
* `git apply` will accept.
|
|
4
|
+
*
|
|
5
|
+
* `diff-model.ts` already parses diffs, but for rendering: `parseRows` throws
|
|
6
|
+
* the file headers away and keeps only line numbers from the hunk header, which
|
|
7
|
+
* is everything a reader needs and nothing a patch needs. Re-emitting is the
|
|
8
|
+
* whole point here, so this model keeps what that one drops.
|
|
9
|
+
*
|
|
10
|
+
* The rules for a partial selection are git's own, from `add -p`, and each one
|
|
11
|
+
* is a statement about the file the patch will be applied to:
|
|
12
|
+
*
|
|
13
|
+
* - a selected `+` line is emitted as `+` — it is being added;
|
|
14
|
+
* - an UNSELECTED `+` line is dropped entirely — it is not part of this
|
|
15
|
+
* patch, and the target does not have it;
|
|
16
|
+
* - a selected `-` line is emitted as `-` — it is being removed;
|
|
17
|
+
* - an UNSELECTED `-` line becomes CONTEXT — the target still has that line,
|
|
18
|
+
* and claiming otherwise makes the patch fail to apply.
|
|
19
|
+
*
|
|
20
|
+
* Those are the rules for a FORWARD apply, where the target holds the patch's
|
|
21
|
+
* pre-image. A patch meant for `git apply --REVERSE` meets the target holding
|
|
22
|
+
* the POST-image, so the unselected treatments mirror: an unselected `+` line
|
|
23
|
+
* becomes context (the target has it) and an unselected `-` line is dropped
|
|
24
|
+
* (the target does not). Confirmed against git itself: reverse-applying a
|
|
25
|
+
* forward-style subset to a working tree that also holds another block's
|
|
26
|
+
* unselected addition fails as "patch does not apply", because the dropped
|
|
27
|
+
* line is missing from the post-image git is trying to match.
|
|
28
|
+
*
|
|
29
|
+
* Both counts are then recomputed from what was actually emitted. That
|
|
30
|
+
* arithmetic is the reason this module exists as a tested unit: a wrong count
|
|
31
|
+
* makes `git apply` fail as "corrupt patch", which tells the reader their diff
|
|
32
|
+
* is broken, when the truth would have been "the file changed under you" —
|
|
33
|
+
* the message the context check is there to produce.
|
|
34
|
+
*
|
|
35
|
+
* Pure: no React, no CSS, no git. `tests/patch-model.test.ts` loads it directly.
|
|
36
|
+
*
|
|
37
|
+
* @module @young1lin/dsh-ui-gitworkbench/patch-model
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** One line of a hunk, with its marker stripped. */
|
|
41
|
+
export interface PatchLine {
|
|
42
|
+
readonly kind: 'add' | 'del' | 'context' | 'nonewline'
|
|
43
|
+
/** Line content without the leading marker. `nonewline` carries ''. */
|
|
44
|
+
readonly text: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One hunk, with the header numbers as the source stated them. */
|
|
48
|
+
export interface Hunk {
|
|
49
|
+
readonly oldStart: number
|
|
50
|
+
readonly oldCount: number
|
|
51
|
+
readonly newStart: number
|
|
52
|
+
readonly newCount: number
|
|
53
|
+
/** Whatever followed the closing `@@`, kept verbatim (git puts the enclosing
|
|
54
|
+
* function there). Empty when the source had none. */
|
|
55
|
+
readonly heading: string
|
|
56
|
+
/** Whether the source wrote `@@ -1 @@` rather than `@@ -1,1 @@`. The unified
|
|
57
|
+
* format allows a count of 1 to be omitted and git's own output does omit
|
|
58
|
+
* it, so re-emitting the other spelling would make a full selection differ
|
|
59
|
+
* from its input for no reason. Only consulted when the emitted count is
|
|
60
|
+
* itself 1 — a count of 3 is never ambiguous. */
|
|
61
|
+
readonly oldCountOmitted: boolean
|
|
62
|
+
readonly newCountOmitted: boolean
|
|
63
|
+
readonly lines: readonly PatchLine[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One file's patch: the headers `git apply` needs, plus its hunks. */
|
|
67
|
+
export interface FilePatch {
|
|
68
|
+
/** `diff --git` through `+++`, verbatim and in order. */
|
|
69
|
+
readonly header: readonly string[]
|
|
70
|
+
readonly hunks: readonly Hunk[]
|
|
71
|
+
/** Whether the source text ended with a newline, so a round trip can too. */
|
|
72
|
+
readonly trailingNewline: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether one changed line is part of the patch being built.
|
|
77
|
+
* Called only for `add` and `del` lines; context is never optional.
|
|
78
|
+
*/
|
|
79
|
+
export type LineSelector = (hunkIndex: number, lineIndex: number) => boolean
|
|
80
|
+
|
|
81
|
+
/** Take the whole diff. */
|
|
82
|
+
export const selectAll: LineSelector = () => true
|
|
83
|
+
/** Take none of it — {@link emitPatch} then returns ''. */
|
|
84
|
+
export const selectNone: LineSelector = () => false
|
|
85
|
+
|
|
86
|
+
const HUNK_HEAD = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse one file's unified diff.
|
|
90
|
+
*
|
|
91
|
+
* @param text - a single file's diff, as `git diff -- <path>` prints it.
|
|
92
|
+
* @returns the parsed patch, or null when the text carries no hunk at all —
|
|
93
|
+
* an empty diff, or a binary one, which says `Binary files … differ`
|
|
94
|
+
* and cannot be applied line by line.
|
|
95
|
+
*/
|
|
96
|
+
export function parsePatch(text: string): FilePatch | null {
|
|
97
|
+
if (text.length === 0) return null
|
|
98
|
+
const lines = text.split('\n')
|
|
99
|
+
const trailingNewline = lines.length > 0 && lines[lines.length - 1] === ''
|
|
100
|
+
if (trailingNewline) lines.pop()
|
|
101
|
+
|
|
102
|
+
const header: string[] = []
|
|
103
|
+
const hunks: Hunk[] = []
|
|
104
|
+
let current: { head: RegExpExecArray; lines: PatchLine[] } | null = null
|
|
105
|
+
|
|
106
|
+
const close = (): void => {
|
|
107
|
+
if (current === null) return
|
|
108
|
+
const [, oldStart, oldCount, newStart, newCount, heading] = current.head
|
|
109
|
+
hunks.push({
|
|
110
|
+
oldStart: Number.parseInt(oldStart!, 10),
|
|
111
|
+
// The unified format allows the count to be omitted when it is 1.
|
|
112
|
+
oldCount: oldCount === undefined ? 1 : Number.parseInt(oldCount, 10),
|
|
113
|
+
newStart: Number.parseInt(newStart!, 10),
|
|
114
|
+
newCount: newCount === undefined ? 1 : Number.parseInt(newCount, 10),
|
|
115
|
+
heading: heading ?? '',
|
|
116
|
+
oldCountOmitted: oldCount === undefined,
|
|
117
|
+
newCountOmitted: newCount === undefined,
|
|
118
|
+
lines: current.lines,
|
|
119
|
+
})
|
|
120
|
+
current = null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const head = HUNK_HEAD.exec(line)
|
|
125
|
+
if (head !== null) {
|
|
126
|
+
close()
|
|
127
|
+
current = { head, lines: [] }
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (current === null) {
|
|
131
|
+
header.push(line)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (line.startsWith('\\')) current.lines.push({ kind: 'nonewline', text: '' })
|
|
135
|
+
else if (line.startsWith('+')) current.lines.push({ kind: 'add', text: line.slice(1) })
|
|
136
|
+
else if (line.startsWith('-')) current.lines.push({ kind: 'del', text: line.slice(1) })
|
|
137
|
+
// A context line is ' ' plus content, but git emits a BARE empty line for
|
|
138
|
+
// an empty one; slicing that would be harmless and reading it as anything
|
|
139
|
+
// else would drop the line.
|
|
140
|
+
else current.lines.push({ kind: 'context', text: line.startsWith(' ') ? line.slice(1) : line })
|
|
141
|
+
}
|
|
142
|
+
close()
|
|
143
|
+
|
|
144
|
+
if (hunks.length === 0) return null
|
|
145
|
+
return { header, hunks, trailingNewline }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Spell a count the way the source spelled it, when that is unambiguous. */
|
|
149
|
+
function count(value: number, omitted: boolean): string {
|
|
150
|
+
return value === 1 && omitted ? '' : `,${value}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface Emitted {
|
|
154
|
+
readonly lines: readonly string[]
|
|
155
|
+
readonly oldCount: number
|
|
156
|
+
readonly newCount: number
|
|
157
|
+
readonly changed: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply the selection rules to one hunk's lines. */
|
|
161
|
+
function emitHunkLines(hunk: Hunk, hunkIndex: number, isSelected: LineSelector, reverseApply: boolean): Emitted {
|
|
162
|
+
const out: string[] = []
|
|
163
|
+
let oldCount = 0
|
|
164
|
+
let newCount = 0
|
|
165
|
+
let changed = false
|
|
166
|
+
// Whether the line the next `\ No newline` marker would describe survived.
|
|
167
|
+
let lastKept = false
|
|
168
|
+
|
|
169
|
+
hunk.lines.forEach((line, lineIndex) => {
|
|
170
|
+
if (line.kind === 'nonewline') {
|
|
171
|
+
// The marker describes the line before it. Emitting it after a line that
|
|
172
|
+
// is no longer in the patch makes git read it as describing a different
|
|
173
|
+
// line entirely.
|
|
174
|
+
if (lastKept) out.push('\')
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (line.kind === 'context') {
|
|
178
|
+
out.push(` ${line.text}`)
|
|
179
|
+
oldCount += 1
|
|
180
|
+
newCount += 1
|
|
181
|
+
lastKept = true
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const selected = isSelected(hunkIndex, lineIndex)
|
|
185
|
+
if (line.kind === 'add') {
|
|
186
|
+
// Forward apply: the target does not have an unselected addition, so it
|
|
187
|
+
// is not part of this patch. Reverse apply: the target DOES have it — it
|
|
188
|
+
// holds the post-image — so it has to be context or the patch will not
|
|
189
|
+
// match.
|
|
190
|
+
if (!selected && !reverseApply) { lastKept = false; return }
|
|
191
|
+
if (!selected) {
|
|
192
|
+
out.push(` ${line.text}`)
|
|
193
|
+
oldCount += 1
|
|
194
|
+
newCount += 1
|
|
195
|
+
lastKept = true
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
out.push(`+${line.text}`)
|
|
199
|
+
newCount += 1
|
|
200
|
+
changed = true
|
|
201
|
+
lastKept = true
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
// A deletion that is not part of this patch still EXISTS in a forward
|
|
205
|
+
// apply's target (the pre-image), so it is presented as context. A reverse
|
|
206
|
+
// apply's target never had it, so it is dropped instead.
|
|
207
|
+
if (!selected) {
|
|
208
|
+
if (reverseApply) { lastKept = false; return }
|
|
209
|
+
out.push(` ${line.text}`)
|
|
210
|
+
oldCount += 1
|
|
211
|
+
newCount += 1
|
|
212
|
+
lastKept = true
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
out.push(`-${line.text}`)
|
|
216
|
+
oldCount += 1
|
|
217
|
+
changed = true
|
|
218
|
+
lastKept = true
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return { lines: out, oldCount, newCount, changed }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Emit the selected part of a patch as text `git apply` will accept.
|
|
226
|
+
*
|
|
227
|
+
* A hunk with nothing selected is dropped whole: a hunk of pure context is
|
|
228
|
+
* valid but pointless, and a patch of nothing but such hunks is a no-op git
|
|
229
|
+
* would still report as applied.
|
|
230
|
+
*
|
|
231
|
+
* The new-side start of each hunk is recomputed by how much the hunks BEFORE
|
|
232
|
+
* it (in this patch) actually shift the file. Taking one of two added lines
|
|
233
|
+
* moves everything after it by one, and a header that still claims the
|
|
234
|
+
* original offset describes a file that will not exist.
|
|
235
|
+
*
|
|
236
|
+
* @param file - a parsed patch.
|
|
237
|
+
* @param isSelected - which changed lines to take.
|
|
238
|
+
* @param reverseApply - emit for `git apply --reverse`, whose target holds the
|
|
239
|
+
* patch's POST-image rather than its pre-image: unselected additions
|
|
240
|
+
* become context and unselected deletions are dropped (the mirror of
|
|
241
|
+
* the forward rules). Selected lines keep their sign either way.
|
|
242
|
+
* @returns the patch text, or '' when the selection is empty. The caller must
|
|
243
|
+
* treat '' as "nothing to do" and make no git call: a patch with no
|
|
244
|
+
* hunks is an error to `git apply`, not a no-op.
|
|
245
|
+
*/
|
|
246
|
+
export function emitPatch(file: FilePatch, isSelected: LineSelector, reverseApply = false): string {
|
|
247
|
+
const body: string[] = []
|
|
248
|
+
// Set from the first hunk that survives, so a patch starting at hunk 2 keeps
|
|
249
|
+
// that hunk's own offset rather than inheriting one from hunks left out.
|
|
250
|
+
let delta: number | null = null
|
|
251
|
+
|
|
252
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
253
|
+
const emitted = emitHunkLines(hunk, hunkIndex, isSelected, reverseApply)
|
|
254
|
+
if (!emitted.changed) return
|
|
255
|
+
if (delta === null) delta = hunk.newStart - hunk.oldStart
|
|
256
|
+
const newStart = hunk.oldStart + delta
|
|
257
|
+
const old = count(emitted.oldCount, hunk.oldCountOmitted)
|
|
258
|
+
const fresh = count(emitted.newCount, hunk.newCountOmitted)
|
|
259
|
+
body.push(`@@ -${hunk.oldStart}${old} +${newStart}${fresh} @@${hunk.heading}`)
|
|
260
|
+
body.push(...emitted.lines)
|
|
261
|
+
delta += emitted.newCount - emitted.oldCount
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
if (body.length === 0) return ''
|
|
265
|
+
const text = [...file.header, ...body].join('\n')
|
|
266
|
+
return file.trailingNewline ? `${text}\n` : text
|
|
267
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side-by-side diff pane's size guard, as a decision rather than two
|
|
3
|
+
* scattered comparisons.
|
|
4
|
+
*
|
|
5
|
+
* The guard exists for the wire, not the reader: a full-context patch carries
|
|
6
|
+
* the WHOLE file, so past these caps "a 2 MB patch per click is not a
|
|
7
|
+
* reasonable wire payload and the side-by-side DOM would be enormous" (the
|
|
8
|
+
* design spec's own words for why the pane declines).
|
|
9
|
+
*
|
|
10
|
+
* Two measurements, because they bound different things:
|
|
11
|
+
*
|
|
12
|
+
* - the TARGET (right-hand) file's byte size and line count — checked
|
|
13
|
+
* BEFORE the diff is produced, so declining a pathological file never
|
|
14
|
+
* means reading it whole first;
|
|
15
|
+
* - the produced diff text itself — checked AFTER, because the target-side
|
|
16
|
+
* check has two blind spots the review caught: a file DELETED from the
|
|
17
|
+
* working tree has no target to measure while its diff is the entire old
|
|
18
|
+
* file as del lines, and a huge LEFT side behind a small target (a 5 MB
|
|
19
|
+
* index blob trimmed to 50 lines) passes the target guard while the patch
|
|
20
|
+
* still carries the 5 MB old side. Only the diff text sees both.
|
|
21
|
+
*
|
|
22
|
+
* Pure: no React, no CSS, no git, no node. `tests/side-guard.test.ts` loads
|
|
23
|
+
* it directly, defeat cases included.
|
|
24
|
+
*
|
|
25
|
+
* @module @young1lin/dsh-ui-gitworkbench/side-guard
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Target side past this many bytes is declined. */
|
|
29
|
+
export const SIDE_BYTE_CAP = 2_000_000
|
|
30
|
+
/** Target side past this many lines is declined. */
|
|
31
|
+
export const SIDE_LINE_CAP = 20_000
|
|
32
|
+
/** Diff text past this many characters is declined — the guard's own 2 MB
|
|
33
|
+
* budget applied to the payload itself, `clipDiff`'s character convention. */
|
|
34
|
+
export const SIDE_DIFF_CHAR_CAP = 2_000_000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The before-the-diff half: does the layer's right-hand file already exceed
|
|
38
|
+
* the guard?
|
|
39
|
+
*
|
|
40
|
+
* @param byteLength - the target's size in bytes.
|
|
41
|
+
* @param lineCount - the target's line count.
|
|
42
|
+
* @returns true when the pane should decline without producing a diff.
|
|
43
|
+
*/
|
|
44
|
+
export function targetTooLarge(byteLength: number, lineCount: number): boolean {
|
|
45
|
+
return byteLength > SIDE_BYTE_CAP || lineCount > SIDE_LINE_CAP
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The after-the-diff half: does the patch text itself exceed the guard? This
|
|
50
|
+
* is what bounds the wire payload when the target measurement cannot — a
|
|
51
|
+
* deleted working-tree file, or a huge left side behind a small target.
|
|
52
|
+
*
|
|
53
|
+
* @param diff - the layer's full-context diff, exactly as it would be returned.
|
|
54
|
+
* @returns true when the pane should decline instead of shipping it.
|
|
55
|
+
*/
|
|
56
|
+
export function diffTooLarge(diff: string): boolean {
|
|
57
|
+
return diff.length > SIDE_DIFF_CHAR_CAP
|
|
58
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `writeChecked` sequence: save the editor's buffer over the working-tree
|
|
3
|
+
* file it was opened from, refusing when the file moved underneath the editor.
|
|
4
|
+
*
|
|
5
|
+
* The sha check is the whole point of this RPC. The drawer shares its
|
|
6
|
+
* worktrees with an agent that writes the same files, and the poll only says
|
|
7
|
+
* how fast the drawer *notices* — so the one hard rule is that a write lands
|
|
8
|
+
* only when the file's blob sha is still the one the editor opened with. A
|
|
9
|
+
* client that lies about that sha gains nothing: the sha is re-derived from
|
|
10
|
+
* what git says at the moment of the write, the same never-trust-the-client
|
|
11
|
+
* shape as `discardFile`'s `expectedEffect`.
|
|
12
|
+
*
|
|
13
|
+
* `expectedSha === ''` means "the file did not exist when I opened it" (a
|
|
14
|
+
* buffer opened on a file deleted meanwhile), and is accepted only while the
|
|
15
|
+
* file is still absent. The not-exist/hash-failure distinction is made
|
|
16
|
+
* explicitly, with a filesystem stat: a spawn error must surface as a failed
|
|
17
|
+
* save, never masquerade as "exists with a different sha" — that would look
|
|
18
|
+
* like the stale case while silently skipping the guard's real work.
|
|
19
|
+
*
|
|
20
|
+
* The sequence's every branch lives here rather than in `index.ts`, because
|
|
21
|
+
* `index.ts` extends the RPC service class and imports its dsh peers as values
|
|
22
|
+
* — vitest cannot load it. Everything git- or filesystem-shaped is injected
|
|
23
|
+
* (`WriteCheckedIo`), which is also what lets the git-backed tests drive this
|
|
24
|
+
* exact code with a real git in a temp repo.
|
|
25
|
+
*
|
|
26
|
+
* There is deliberately no `writeFile(path, content)` without the sha check
|
|
27
|
+
* anywhere in this plugin, and none may be added: an unchecked write is a
|
|
28
|
+
* clobber-a-concurrent-agent primitive.
|
|
29
|
+
*
|
|
30
|
+
* @module @young1lin/dsh-ui-gitworkbench/write-checked
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomBytes } from 'node:crypto'
|
|
34
|
+
|
|
35
|
+
import { renameWithRetry } from './atomic-json.js'
|
|
36
|
+
import { resolveInside } from './fs-remove.js'
|
|
37
|
+
import { decodesAsUtf8, isSafePathArg, type OpFailure } from './git-ops.js'
|
|
38
|
+
import type { GitRun } from './apply-blocks.js'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `gitWorkbench/writeChecked`'s answer. Optional fields obey the gateway's
|
|
42
|
+
* JSON rule: success emits `ok` and `sha` and omits `failure`/`error`
|
|
43
|
+
* entirely; failure omits `sha`.
|
|
44
|
+
*/
|
|
45
|
+
export interface WriteResult {
|
|
46
|
+
readonly ok: boolean
|
|
47
|
+
/** Present only on failure; `stale` is the sha refusal this RPC exists for. */
|
|
48
|
+
readonly failure?: OpFailure
|
|
49
|
+
/** A sentence for the user; names the file on the stale path. */
|
|
50
|
+
readonly error?: string
|
|
51
|
+
/** The file's blob sha after a successful write, for the next save. */
|
|
52
|
+
readonly sha?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Everything the sequence needs from its host, as injected dependencies.
|
|
57
|
+
*
|
|
58
|
+
* `index.ts` binds its own `git()` spawn helper and the fs/promises calls; the
|
|
59
|
+
* tests bind a real git in a temp repo and the real filesystem. Either way the
|
|
60
|
+
* decisions below are the same code — there is no second implementation to
|
|
61
|
+
* fall out of step with.
|
|
62
|
+
*/
|
|
63
|
+
export interface WriteCheckedIo {
|
|
64
|
+
/** Run git in `cwd`. Must not throw — report through `exitCode`/`stderr`. */
|
|
65
|
+
readonly git: (cwd: string, argv: readonly string[]) => Promise<GitRun>
|
|
66
|
+
/** Whether the path exists on disk (the explicit not-exist check). */
|
|
67
|
+
readonly exists: (path: string) => Promise<boolean>
|
|
68
|
+
/** The file's raw bytes, for the encoding check. Throws if unreadable. */
|
|
69
|
+
readonly readBytes: (path: string) => Promise<Buffer>
|
|
70
|
+
/** Write whole bytes to a path — the buffer as-is, LF, no translation. */
|
|
71
|
+
readonly writeBytes: (path: string, bytes: Uint8Array) => Promise<void>
|
|
72
|
+
/** Renames a path over another; retried by {@link renameWithRetry}. */
|
|
73
|
+
readonly rename: (from: string, to: string) => Promise<void>
|
|
74
|
+
/** Best-effort temp cleanup; a file already gone is a success. */
|
|
75
|
+
readonly remove: (path: string) => Promise<void>
|
|
76
|
+
/** Waits the given milliseconds — the rename retry backoff. */
|
|
77
|
+
readonly delay: (ms: number) => Promise<void>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run one checked write end to end. Never throws: every failure, including an
|
|
82
|
+
* IO failure, is a result the RPC can carry back as a sentence.
|
|
83
|
+
*
|
|
84
|
+
* The order of the steps is the contract: the path lock, then the sha as git
|
|
85
|
+
* reads it RIGHT NOW, then the refusal before anything is staged, then the
|
|
86
|
+
* atomic write, and only then the sha the next save will be checked against.
|
|
87
|
+
*/
|
|
88
|
+
export async function runWriteChecked(
|
|
89
|
+
io: WriteCheckedIo,
|
|
90
|
+
cwd: string,
|
|
91
|
+
path: string,
|
|
92
|
+
text: string,
|
|
93
|
+
expectedSha: string,
|
|
94
|
+
): Promise<WriteResult> {
|
|
95
|
+
try {
|
|
96
|
+
return await writeChecked(io, cwd, path, text, expectedSha)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Nothing may throw across the RPC boundary: a failed helper is a failed
|
|
99
|
+
// save with a message, not a broken call.
|
|
100
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function writeChecked(
|
|
105
|
+
io: WriteCheckedIo,
|
|
106
|
+
cwd: string,
|
|
107
|
+
path: string,
|
|
108
|
+
text: string,
|
|
109
|
+
expectedSha: string,
|
|
110
|
+
): Promise<WriteResult> {
|
|
111
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
112
|
+
return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` }
|
|
113
|
+
}
|
|
114
|
+
if (typeof text !== 'string' || typeof expectedSha !== 'string') {
|
|
115
|
+
return { ok: false, failure: 'invalid', error: 'text and expectedSha must be strings' }
|
|
116
|
+
}
|
|
117
|
+
// The path lock every filesystem write in this plugin passes — the same
|
|
118
|
+
// `resolveInside`, not a second one. It throws on traversal spellings and on
|
|
119
|
+
// anything resolving outside the worktree.
|
|
120
|
+
let target: string
|
|
121
|
+
try {
|
|
122
|
+
target = resolveInside(cwd, path)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { ok: false, failure: 'invalid', error: error instanceof Error ? error.message : String(error) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Encoding before anything else, because it is a permanent property of the
|
|
128
|
+
// file rather than a race. The buffer reached the browser as a UTF-8 decode
|
|
129
|
+
// of these bytes; for a file in any other encoding that decode was LOSSY,
|
|
130
|
+
// and writing the result back replaces every non-ASCII byte in the file —
|
|
131
|
+
// including the lines nobody edited. The client withholds the editor for
|
|
132
|
+
// such a file, and this refuses the write regardless, because a client's
|
|
133
|
+
// word is not what this RPC trusts.
|
|
134
|
+
let current: string
|
|
135
|
+
const present = await io.exists(target)
|
|
136
|
+
if (present) {
|
|
137
|
+
let bytes: Buffer
|
|
138
|
+
try {
|
|
139
|
+
bytes = await io.readBytes(target)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false, failure: 'unknown',
|
|
143
|
+
error: `could not read ${path} to check its encoding: ${error instanceof Error ? error.message : String(error)}`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!decodesAsUtf8(bytes)) {
|
|
147
|
+
return { ok: false, failure: 'invalid', error: notUtf8Message(path) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The sha as git reads it now. Absent is '', by stat rather than by reading
|
|
152
|
+
// a spawn failure into it; a file that IS there but will not hash is a
|
|
153
|
+
// failed save, never a stale one.
|
|
154
|
+
if (!present) {
|
|
155
|
+
current = ''
|
|
156
|
+
} else {
|
|
157
|
+
const hashed = await io.git(cwd, ['hash-object', '--', path])
|
|
158
|
+
if (hashed.exitCode !== 0) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false, failure: 'unknown',
|
|
161
|
+
error: `git hash-object failed (exit ${hashed.exitCode}) for ${path}${hashed.stderr.length > 0 ? `: ${hashed.stderr}` : ''}`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
current = hashed.stdout.trim()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (current !== expectedSha) {
|
|
168
|
+
return { ok: false, failure: 'stale', error: staleMessage(path, expectedSha, current) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Atomic write: stage the bytes beside the target (same directory, so the
|
|
172
|
+
// rename stays inside one filesystem), then rename over it with the shared
|
|
173
|
+
// Windows-EPERM retry. The buffer is written as bytes exactly as received —
|
|
174
|
+
// no newline translation anywhere in this path.
|
|
175
|
+
const tmp = `${target}.gwtmp-${randomBytes(6).toString('hex')}`
|
|
176
|
+
try {
|
|
177
|
+
await io.writeBytes(tmp, Buffer.from(text, 'utf8'))
|
|
178
|
+
await renameWithRetry(io.rename, io.delay, tmp, target)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// The write did not land; the temp must not linger in the tree as a
|
|
181
|
+
// phantom untracked file. Cleanup must never mask the result it follows.
|
|
182
|
+
await io.remove(tmp).catch(() => {})
|
|
183
|
+
return {
|
|
184
|
+
ok: false, failure: 'unknown',
|
|
185
|
+
error: `could not write ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The sha the NEXT save is checked against, read back from the file that is
|
|
190
|
+
// now on disk.
|
|
191
|
+
const after = await io.git(cwd, ['hash-object', '--', path])
|
|
192
|
+
if (after.exitCode !== 0) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false, failure: 'unknown',
|
|
195
|
+
error: `${path} was written but its new sha could not be read (exit ${after.exitCode})${after.stderr.length > 0 ? `: ${after.stderr}` : ''}`,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, sha: after.stdout.trim() }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Why an encoding refusal happened, in the reader's terms. It names the path
|
|
203
|
+
* for the same reason the stale message does — the banner sits under a file
|
|
204
|
+
* tab the reader may already have switched away from.
|
|
205
|
+
*/
|
|
206
|
+
function notUtf8Message(path: string): string {
|
|
207
|
+
return `${path} is not UTF-8, so editing it here would rewrite every non-ASCII byte; nothing was written`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Why the save was refused, in the reader's terms: which way the file moved.
|
|
212
|
+
* Every spelling names the path, because the banner this lands in sits under a
|
|
213
|
+
* file tab the reader may already have switched away from.
|
|
214
|
+
*/
|
|
215
|
+
function staleMessage(path: string, expectedSha: string, current: string): string {
|
|
216
|
+
if (expectedSha === '') {
|
|
217
|
+
return `${path} was created while you were editing it; nothing was written`
|
|
218
|
+
}
|
|
219
|
+
if (current === '') {
|
|
220
|
+
return `${path} was deleted while you were editing it; nothing was written`
|
|
221
|
+
}
|
|
222
|
+
return `${path} changed while you were editing it; nothing was written`
|
|
223
|
+
}
|