@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG_EN.md +42 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34811 -11818
  7. package/lib/fs-remove.js +73 -0
  8. package/lib/git-ops.js +25 -0
  9. package/lib/image-sniff.js +197 -0
  10. package/lib/index.js +404 -32
  11. package/lib/patch-model.js +223 -0
  12. package/lib/side-guard.js +55 -0
  13. package/lib/write-checked.js +164 -0
  14. package/package.json +7 -1
  15. package/src/apply-blocks.ts +215 -0
  16. package/src/atomic-json.ts +29 -5
  17. package/src/blame.ts +94 -0
  18. package/src/client/CodeEditor.tsx +317 -0
  19. package/src/client/FileBrowser.tsx +657 -0
  20. package/src/client/GitWorkbenchPanel.module.css +453 -7
  21. package/src/client/GitWorkbenchPanel.tsx +1465 -166
  22. package/src/client/ImageView.tsx +120 -0
  23. package/src/client/blame-gutter.ts +108 -0
  24. package/src/client/blame-view.ts +104 -0
  25. package/src/client/cm-diff.ts +108 -0
  26. package/src/client/cm-tokens.ts +79 -0
  27. package/src/client/diff-nav.ts +198 -0
  28. package/src/client/discard-flow.ts +82 -0
  29. package/src/client/file-icon.ts +190 -0
  30. package/src/client/file-rows.ts +184 -0
  31. package/src/client/files-place.ts +178 -0
  32. package/src/client/glyphs.tsx +86 -0
  33. package/src/client/highlight.ts +25 -0
  34. package/src/client/idle-value.ts +53 -0
  35. package/src/client/image-view.ts +106 -0
  36. package/src/client/indent.ts +74 -0
  37. package/src/client/index.ts +76 -9
  38. package/src/client/locales.ts +171 -4
  39. package/src/client/pane-size.ts +71 -0
  40. package/src/client/side-edit.ts +244 -0
  41. package/src/client/side-rows.ts +258 -0
  42. package/src/client/stable-list.ts +31 -0
  43. package/src/client/use-change-nav.ts +83 -0
  44. package/src/client/worktree-view.ts +11 -1
  45. package/src/fs-remove.ts +76 -0
  46. package/src/git-ops.ts +36 -1
  47. package/src/image-sniff.ts +204 -0
  48. package/src/index.ts +450 -32
  49. package/src/patch-model.ts +267 -0
  50. package/src/side-guard.ts +58 -0
  51. 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
+ }