@young1lin/dsh-ui-gitworkbench 0.1.5 → 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 (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CHANGELOG_EN.md +33 -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 +24360 -1436
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +453 -7
  20. package/src/client/GitWorkbenchPanel.tsx +1436 -143
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/idle-value.ts +53 -0
  33. package/src/client/image-view.ts +106 -0
  34. package/src/client/indent.ts +74 -0
  35. package/src/client/index.ts +59 -0
  36. package/src/client/locales.ts +171 -4
  37. package/src/client/pane-size.ts +71 -0
  38. package/src/client/side-edit.ts +244 -0
  39. package/src/client/side-rows.ts +258 -0
  40. package/src/client/stable-list.ts +31 -0
  41. package/src/client/use-change-nav.ts +83 -0
  42. package/src/client/worktree-view.ts +11 -1
  43. package/src/git-ops.ts +36 -1
  44. package/src/image-sniff.ts +204 -0
  45. package/src/index.ts +447 -7
  46. package/src/patch-model.ts +267 -0
  47. package/src/side-guard.ts +58 -0
  48. package/src/write-checked.ts +223 -0
@@ -0,0 +1,223 @@
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
+ /** Take the whole diff. */
40
+ export const selectAll = () => true;
41
+ /** Take none of it — {@link emitPatch} then returns ''. */
42
+ export const selectNone = () => false;
43
+ const HUNK_HEAD = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
44
+ /**
45
+ * Parse one file's unified diff.
46
+ *
47
+ * @param text - a single file's diff, as `git diff -- <path>` prints it.
48
+ * @returns the parsed patch, or null when the text carries no hunk at all —
49
+ * an empty diff, or a binary one, which says `Binary files … differ`
50
+ * and cannot be applied line by line.
51
+ */
52
+ export function parsePatch(text) {
53
+ if (text.length === 0)
54
+ return null;
55
+ const lines = text.split('\n');
56
+ const trailingNewline = lines.length > 0 && lines[lines.length - 1] === '';
57
+ if (trailingNewline)
58
+ lines.pop();
59
+ const header = [];
60
+ const hunks = [];
61
+ let current = null;
62
+ const close = () => {
63
+ if (current === null)
64
+ return;
65
+ const [, oldStart, oldCount, newStart, newCount, heading] = current.head;
66
+ hunks.push({
67
+ oldStart: Number.parseInt(oldStart, 10),
68
+ // The unified format allows the count to be omitted when it is 1.
69
+ oldCount: oldCount === undefined ? 1 : Number.parseInt(oldCount, 10),
70
+ newStart: Number.parseInt(newStart, 10),
71
+ newCount: newCount === undefined ? 1 : Number.parseInt(newCount, 10),
72
+ heading: heading ?? '',
73
+ oldCountOmitted: oldCount === undefined,
74
+ newCountOmitted: newCount === undefined,
75
+ lines: current.lines,
76
+ });
77
+ current = null;
78
+ };
79
+ for (const line of lines) {
80
+ const head = HUNK_HEAD.exec(line);
81
+ if (head !== null) {
82
+ close();
83
+ current = { head, lines: [] };
84
+ continue;
85
+ }
86
+ if (current === null) {
87
+ header.push(line);
88
+ continue;
89
+ }
90
+ if (line.startsWith('\\'))
91
+ current.lines.push({ kind: 'nonewline', text: '' });
92
+ else if (line.startsWith('+'))
93
+ current.lines.push({ kind: 'add', text: line.slice(1) });
94
+ else if (line.startsWith('-'))
95
+ current.lines.push({ kind: 'del', text: line.slice(1) });
96
+ // A context line is ' ' plus content, but git emits a BARE empty line for
97
+ // an empty one; slicing that would be harmless and reading it as anything
98
+ // else would drop the line.
99
+ else
100
+ current.lines.push({ kind: 'context', text: line.startsWith(' ') ? line.slice(1) : line });
101
+ }
102
+ close();
103
+ if (hunks.length === 0)
104
+ return null;
105
+ return { header, hunks, trailingNewline };
106
+ }
107
+ /** Spell a count the way the source spelled it, when that is unambiguous. */
108
+ function count(value, omitted) {
109
+ return value === 1 && omitted ? '' : `,${value}`;
110
+ }
111
+ /** Apply the selection rules to one hunk's lines. */
112
+ function emitHunkLines(hunk, hunkIndex, isSelected, reverseApply) {
113
+ const out = [];
114
+ let oldCount = 0;
115
+ let newCount = 0;
116
+ let changed = false;
117
+ // Whether the line the next `\ No newline` marker would describe survived.
118
+ let lastKept = false;
119
+ hunk.lines.forEach((line, lineIndex) => {
120
+ if (line.kind === 'nonewline') {
121
+ // The marker describes the line before it. Emitting it after a line that
122
+ // is no longer in the patch makes git read it as describing a different
123
+ // line entirely.
124
+ if (lastKept)
125
+ out.push('\');
126
+ return;
127
+ }
128
+ if (line.kind === 'context') {
129
+ out.push(` ${line.text}`);
130
+ oldCount += 1;
131
+ newCount += 1;
132
+ lastKept = true;
133
+ return;
134
+ }
135
+ const selected = isSelected(hunkIndex, lineIndex);
136
+ if (line.kind === 'add') {
137
+ // Forward apply: the target does not have an unselected addition, so it
138
+ // is not part of this patch. Reverse apply: the target DOES have it — it
139
+ // holds the post-image — so it has to be context or the patch will not
140
+ // match.
141
+ if (!selected && !reverseApply) {
142
+ lastKept = false;
143
+ return;
144
+ }
145
+ if (!selected) {
146
+ out.push(` ${line.text}`);
147
+ oldCount += 1;
148
+ newCount += 1;
149
+ lastKept = true;
150
+ return;
151
+ }
152
+ out.push(`+${line.text}`);
153
+ newCount += 1;
154
+ changed = true;
155
+ lastKept = true;
156
+ return;
157
+ }
158
+ // A deletion that is not part of this patch still EXISTS in a forward
159
+ // apply's target (the pre-image), so it is presented as context. A reverse
160
+ // apply's target never had it, so it is dropped instead.
161
+ if (!selected) {
162
+ if (reverseApply) {
163
+ lastKept = false;
164
+ return;
165
+ }
166
+ out.push(` ${line.text}`);
167
+ oldCount += 1;
168
+ newCount += 1;
169
+ lastKept = true;
170
+ return;
171
+ }
172
+ out.push(`-${line.text}`);
173
+ oldCount += 1;
174
+ changed = true;
175
+ lastKept = true;
176
+ });
177
+ return { lines: out, oldCount, newCount, changed };
178
+ }
179
+ /**
180
+ * Emit the selected part of a patch as text `git apply` will accept.
181
+ *
182
+ * A hunk with nothing selected is dropped whole: a hunk of pure context is
183
+ * valid but pointless, and a patch of nothing but such hunks is a no-op git
184
+ * would still report as applied.
185
+ *
186
+ * The new-side start of each hunk is recomputed by how much the hunks BEFORE
187
+ * it (in this patch) actually shift the file. Taking one of two added lines
188
+ * moves everything after it by one, and a header that still claims the
189
+ * original offset describes a file that will not exist.
190
+ *
191
+ * @param file - a parsed patch.
192
+ * @param isSelected - which changed lines to take.
193
+ * @param reverseApply - emit for `git apply --reverse`, whose target holds the
194
+ * patch's POST-image rather than its pre-image: unselected additions
195
+ * become context and unselected deletions are dropped (the mirror of
196
+ * the forward rules). Selected lines keep their sign either way.
197
+ * @returns the patch text, or '' when the selection is empty. The caller must
198
+ * treat '' as "nothing to do" and make no git call: a patch with no
199
+ * hunks is an error to `git apply`, not a no-op.
200
+ */
201
+ export function emitPatch(file, isSelected, reverseApply = false) {
202
+ const body = [];
203
+ // Set from the first hunk that survives, so a patch starting at hunk 2 keeps
204
+ // that hunk's own offset rather than inheriting one from hunks left out.
205
+ let delta = null;
206
+ file.hunks.forEach((hunk, hunkIndex) => {
207
+ const emitted = emitHunkLines(hunk, hunkIndex, isSelected, reverseApply);
208
+ if (!emitted.changed)
209
+ return;
210
+ if (delta === null)
211
+ delta = hunk.newStart - hunk.oldStart;
212
+ const newStart = hunk.oldStart + delta;
213
+ const old = count(emitted.oldCount, hunk.oldCountOmitted);
214
+ const fresh = count(emitted.newCount, hunk.newCountOmitted);
215
+ body.push(`@@ -${hunk.oldStart}${old} +${newStart}${fresh} @@${hunk.heading}`);
216
+ body.push(...emitted.lines);
217
+ delta += emitted.newCount - emitted.oldCount;
218
+ });
219
+ if (body.length === 0)
220
+ return '';
221
+ const text = [...file.header, ...body].join('\n');
222
+ return file.trailingNewline ? `${text}\n` : text;
223
+ }
@@ -0,0 +1,55 @@
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
+ /** Target side past this many bytes is declined. */
28
+ export const SIDE_BYTE_CAP = 2_000_000;
29
+ /** Target side past this many lines is declined. */
30
+ export const SIDE_LINE_CAP = 20_000;
31
+ /** Diff text past this many characters is declined — the guard's own 2 MB
32
+ * budget applied to the payload itself, `clipDiff`'s character convention. */
33
+ export const SIDE_DIFF_CHAR_CAP = 2_000_000;
34
+ /**
35
+ * The before-the-diff half: does the layer's right-hand file already exceed
36
+ * the guard?
37
+ *
38
+ * @param byteLength - the target's size in bytes.
39
+ * @param lineCount - the target's line count.
40
+ * @returns true when the pane should decline without producing a diff.
41
+ */
42
+ export function targetTooLarge(byteLength, lineCount) {
43
+ return byteLength > SIDE_BYTE_CAP || lineCount > SIDE_LINE_CAP;
44
+ }
45
+ /**
46
+ * The after-the-diff half: does the patch text itself exceed the guard? This
47
+ * is what bounds the wire payload when the target measurement cannot — a
48
+ * deleted working-tree file, or a huge left side behind a small target.
49
+ *
50
+ * @param diff - the layer's full-context diff, exactly as it would be returned.
51
+ * @returns true when the pane should decline instead of shipping it.
52
+ */
53
+ export function diffTooLarge(diff) {
54
+ return diff.length > SIDE_DIFF_CHAR_CAP;
55
+ }
@@ -0,0 +1,164 @@
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
+ import { randomBytes } from 'node:crypto';
33
+ import { renameWithRetry } from './atomic-json.js';
34
+ import { resolveInside } from './fs-remove.js';
35
+ import { decodesAsUtf8, isSafePathArg } from './git-ops.js';
36
+ /**
37
+ * Run one checked write end to end. Never throws: every failure, including an
38
+ * IO failure, is a result the RPC can carry back as a sentence.
39
+ *
40
+ * The order of the steps is the contract: the path lock, then the sha as git
41
+ * reads it RIGHT NOW, then the refusal before anything is staged, then the
42
+ * atomic write, and only then the sha the next save will be checked against.
43
+ */
44
+ export async function runWriteChecked(io, cwd, path, text, expectedSha) {
45
+ try {
46
+ return await writeChecked(io, cwd, path, text, expectedSha);
47
+ }
48
+ catch (error) {
49
+ // Nothing may throw across the RPC boundary: a failed helper is a failed
50
+ // save with a message, not a broken call.
51
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
52
+ }
53
+ }
54
+ async function writeChecked(io, cwd, path, text, expectedSha) {
55
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
56
+ return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` };
57
+ }
58
+ if (typeof text !== 'string' || typeof expectedSha !== 'string') {
59
+ return { ok: false, failure: 'invalid', error: 'text and expectedSha must be strings' };
60
+ }
61
+ // The path lock every filesystem write in this plugin passes — the same
62
+ // `resolveInside`, not a second one. It throws on traversal spellings and on
63
+ // anything resolving outside the worktree.
64
+ let target;
65
+ try {
66
+ target = resolveInside(cwd, path);
67
+ }
68
+ catch (error) {
69
+ return { ok: false, failure: 'invalid', error: error instanceof Error ? error.message : String(error) };
70
+ }
71
+ // Encoding before anything else, because it is a permanent property of the
72
+ // file rather than a race. The buffer reached the browser as a UTF-8 decode
73
+ // of these bytes; for a file in any other encoding that decode was LOSSY,
74
+ // and writing the result back replaces every non-ASCII byte in the file —
75
+ // including the lines nobody edited. The client withholds the editor for
76
+ // such a file, and this refuses the write regardless, because a client's
77
+ // word is not what this RPC trusts.
78
+ let current;
79
+ const present = await io.exists(target);
80
+ if (present) {
81
+ let bytes;
82
+ try {
83
+ bytes = await io.readBytes(target);
84
+ }
85
+ catch (error) {
86
+ return {
87
+ ok: false, failure: 'unknown',
88
+ error: `could not read ${path} to check its encoding: ${error instanceof Error ? error.message : String(error)}`,
89
+ };
90
+ }
91
+ if (!decodesAsUtf8(bytes)) {
92
+ return { ok: false, failure: 'invalid', error: notUtf8Message(path) };
93
+ }
94
+ }
95
+ // The sha as git reads it now. Absent is '', by stat rather than by reading
96
+ // a spawn failure into it; a file that IS there but will not hash is a
97
+ // failed save, never a stale one.
98
+ if (!present) {
99
+ current = '';
100
+ }
101
+ else {
102
+ const hashed = await io.git(cwd, ['hash-object', '--', path]);
103
+ if (hashed.exitCode !== 0) {
104
+ return {
105
+ ok: false, failure: 'unknown',
106
+ error: `git hash-object failed (exit ${hashed.exitCode}) for ${path}${hashed.stderr.length > 0 ? `: ${hashed.stderr}` : ''}`,
107
+ };
108
+ }
109
+ current = hashed.stdout.trim();
110
+ }
111
+ if (current !== expectedSha) {
112
+ return { ok: false, failure: 'stale', error: staleMessage(path, expectedSha, current) };
113
+ }
114
+ // Atomic write: stage the bytes beside the target (same directory, so the
115
+ // rename stays inside one filesystem), then rename over it with the shared
116
+ // Windows-EPERM retry. The buffer is written as bytes exactly as received —
117
+ // no newline translation anywhere in this path.
118
+ const tmp = `${target}.gwtmp-${randomBytes(6).toString('hex')}`;
119
+ try {
120
+ await io.writeBytes(tmp, Buffer.from(text, 'utf8'));
121
+ await renameWithRetry(io.rename, io.delay, tmp, target);
122
+ }
123
+ catch (error) {
124
+ // The write did not land; the temp must not linger in the tree as a
125
+ // phantom untracked file. Cleanup must never mask the result it follows.
126
+ await io.remove(tmp).catch(() => { });
127
+ return {
128
+ ok: false, failure: 'unknown',
129
+ error: `could not write ${path}: ${error instanceof Error ? error.message : String(error)}`,
130
+ };
131
+ }
132
+ // The sha the NEXT save is checked against, read back from the file that is
133
+ // now on disk.
134
+ const after = await io.git(cwd, ['hash-object', '--', path]);
135
+ if (after.exitCode !== 0) {
136
+ return {
137
+ ok: false, failure: 'unknown',
138
+ error: `${path} was written but its new sha could not be read (exit ${after.exitCode})${after.stderr.length > 0 ? `: ${after.stderr}` : ''}`,
139
+ };
140
+ }
141
+ return { ok: true, sha: after.stdout.trim() };
142
+ }
143
+ /**
144
+ * Why an encoding refusal happened, in the reader's terms. It names the path
145
+ * for the same reason the stale message does — the banner sits under a file
146
+ * tab the reader may already have switched away from.
147
+ */
148
+ function notUtf8Message(path) {
149
+ return `${path} is not UTF-8, so editing it here would rewrite every non-ASCII byte; nothing was written`;
150
+ }
151
+ /**
152
+ * Why the save was refused, in the reader's terms: which way the file moved.
153
+ * Every spelling names the path, because the banner this lands in sits under a
154
+ * file tab the reader may already have switched away from.
155
+ */
156
+ function staleMessage(path, expectedSha, current) {
157
+ if (expectedSha === '') {
158
+ return `${path} was created while you were editing it; nothing was written`;
159
+ }
160
+ if (current === '') {
161
+ return `${path} was deleted while you were editing it; nothing was written`;
162
+ }
163
+ return `${path} changed while you were editing it; nothing was written`;
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -82,6 +82,12 @@
82
82
  "vitest": "^4.1.10"
83
83
  },
84
84
  "dependencies": {
85
+ "@codemirror/commands": "6.11.0",
86
+ "@codemirror/language": "6.12.4",
87
+ "@codemirror/merge": "6.12.2",
88
+ "@codemirror/search": "6.7.1",
89
+ "@codemirror/state": "6.7.1",
90
+ "@codemirror/view": "6.43.9",
85
91
  "@shikijs/langs": "4.3.1",
86
92
  "shiki": "4.3.1"
87
93
  }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The `applyBlocks` sequence: turn "these hunk lines of this block" into a git
3
+ * index or working-tree mutation.
4
+ *
5
+ * The client sends a SELECTION, never patch text — a patch is a file-addressing
6
+ * format, and accepting one from the browser would be a write primitive with a
7
+ * path argument. So the host re-fetches the layer's diff itself, proves the
8
+ * client is describing the same snapshot (sha1 of the fetched text must equal
9
+ * the `diffSha` the pane rendered), and only then emits and applies.
10
+ *
11
+ * The sequence's every branch lives here rather than in `index.ts`, because
12
+ * `index.ts` extends the RPC service class and imports its dsh peers as values
13
+ * — vitest cannot load it. Everything git- or filesystem-shaped is injected
14
+ * (`ApplyBlocksIo`), which is also what lets the git-backed tests drive this
15
+ * exact code with a real git in a temp repo instead of a mock of it.
16
+ *
17
+ * Nothing here builds a destructive command: the whole vocabulary is
18
+ * `git apply`, forward into the index (`--cached`) or reverse out of the index
19
+ * or the working tree (`--reverse`). The one argv that carries file content is
20
+ * the host-written tmpfile, never a client string; the client's `path` is
21
+ * checked with `isSafePathArg` and reaches git only inside the layer-diff
22
+ * fetch, behind `--`, as every other pathspec in this plugin is.
23
+ *
24
+ * @module @young1lin/dsh-ui-gitworkbench/apply-blocks
25
+ */
26
+
27
+ import { createHash } from 'node:crypto'
28
+
29
+ import { classifyFailure, isSafePathArg, type OpFailure } from './git-ops.js'
30
+ import { emitPatch, parsePatch, type LineSelector } from './patch-model.js'
31
+
32
+ /**
33
+ * Whether a mode applies its patch with `--reverse`, which decides the
34
+ * emission rules: a reverse apply's target holds the patch's post-image (the
35
+ * index for `unstage`, the working tree for `discard`), so unselected lines
36
+ * must be presented the post-image sees them — `emitPatch`'s third argument.
37
+ */
38
+ function appliesInReverse(mode: string): boolean {
39
+ return mode !== 'stage'
40
+ }
41
+
42
+ /** The three block mutations, in the client's vocabulary. */
43
+ export type ApplyMode = 'stage' | 'unstage' | 'discard'
44
+
45
+ /** One git run, in the shape `index.ts`'s `git()` helper already returns. */
46
+ export interface GitRun {
47
+ readonly stdout: string
48
+ readonly exitCode: number
49
+ readonly stderr: string
50
+ }
51
+
52
+ /** The result of one `applyBlocks` call — `GitOpResult`, structurally. */
53
+ export interface ApplyBlocksResult {
54
+ readonly ok: boolean
55
+ readonly failure?: OpFailure
56
+ readonly error?: string
57
+ }
58
+
59
+ /**
60
+ * Everything the sequence needs from its host, as injected dependencies.
61
+ *
62
+ * `index.ts` binds its own `git()` spawn helper, the layer-diff fetch it shares
63
+ * with `fileSides`, and the tmpfile pair; the tests bind a real git in a temp
64
+ * repo and a patch file inside it. Either way the decisions below are the same
65
+ * code — there is no second implementation to fall out of step with.
66
+ */
67
+ export interface ApplyBlocksIo {
68
+ /** Run git in `cwd`. Must not throw — report through `exitCode`/`stderr`. */
69
+ readonly git: (cwd: string, argv: readonly string[]) => Promise<GitRun>
70
+ /** The layer's full-context diff — the exact fetch `fileSides` serves. */
71
+ readonly layerDiff: (path: string, layer: string) => Promise<string>
72
+ /** Write patch text where git can read it; returns the path. */
73
+ readonly writePatch: (text: string) => Promise<string>
74
+ /** Delete what `writePatch` produced. Called from a `finally`. */
75
+ readonly dropPatch: (path: string) => Promise<void>
76
+ }
77
+
78
+ /**
79
+ * The mode/layer matrix as argv: which `git apply` spelling carries each
80
+ * mutation, and the one layer each is valid on.
81
+ *
82
+ * @returns the argv head for the mode, or null when the pair is not one the
83
+ * design defines — the caller reports it, never guesses a near one.
84
+ */
85
+ export function applyArgvFor(mode: string, layer: string): readonly string[] | null {
86
+ if (mode === 'stage' && layer === 'unstaged') return ['apply', '--cached']
87
+ if (mode === 'unstage' && layer === 'staged') return ['apply', '--cached', '--reverse']
88
+ if (mode === 'discard' && layer === 'unstaged') return ['apply', '--reverse']
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * sha1 of a string, hex — the `diffSha` contract.
94
+ *
95
+ * This was a private helper of `index.ts` until the checker needed to be the
96
+ * same code as the producer: `fileSides` stamps the diff it returns and
97
+ * `applyBlocks` re-derives the stamp over its own fresh fetch, and the two ends
98
+ * of that comparison must be one function or the comparison means nothing.
99
+ */
100
+ export function sha1Hex(text: string): string {
101
+ return createHash('sha1').update(text, 'utf8').digest('hex')
102
+ }
103
+
104
+ /**
105
+ * The selector `emitPatch` takes for a block's line indices: a hunk line is
106
+ * selected iff its index appears in `lines`.
107
+ *
108
+ * The indices are positional into the one hunk's `lines` — the same array
109
+ * `side-rows.blockLines` read client-side. Only integers ≥ 0 count: `lines`
110
+ * crosses the RPC boundary untyped, and a stray string or float selecting
111
+ * nothing is the safe direction (an empty selection is a no-op, not an error).
112
+ */
113
+ export function lineSelector(lines: readonly number[]): LineSelector {
114
+ const list = Array.isArray(lines) ? lines : []
115
+ const wanted = new Set(list.filter(line => Number.isInteger(line) && line >= 0))
116
+ return (_hunkIndex: number, lineIndex: number) => wanted.has(lineIndex)
117
+ }
118
+
119
+ /**
120
+ * Run one block mutation end to end. Never throws: every failure, including an
121
+ * IO failure, is a result the RPC can carry back as a sentence.
122
+ *
123
+ * The order of the steps is the contract: the stale check before anything is
124
+ * emitted, the multi-hunk guard before anything is applied, `--check` before
125
+ * the apply that matters, and the tmpfile deleted whichever way it ends.
126
+ */
127
+ export async function runApplyBlocks(
128
+ io: ApplyBlocksIo,
129
+ cwd: string,
130
+ path: string,
131
+ layer: string,
132
+ diffSha: string,
133
+ lines: readonly number[],
134
+ mode: string,
135
+ ): Promise<ApplyBlocksResult> {
136
+ try {
137
+ return await applyBlocksChecked(io, cwd, path, layer, diffSha, lines, mode)
138
+ } catch (error) {
139
+ // Nothing may throw across the RPC boundary: a failed helper is a failed
140
+ // operation with a message, not a broken call.
141
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
142
+ }
143
+ }
144
+
145
+ async function applyBlocksChecked(
146
+ io: ApplyBlocksIo,
147
+ cwd: string,
148
+ path: string,
149
+ layer: string,
150
+ diffSha: string,
151
+ lines: readonly number[],
152
+ mode: string,
153
+ ): Promise<ApplyBlocksResult> {
154
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
155
+ return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` }
156
+ }
157
+ const argv = applyArgvFor(mode, layer)
158
+ if (argv === null) {
159
+ return { ok: false, failure: 'invalid', error: `cannot ${String(mode)} a block on the ${String(layer)} layer` }
160
+ }
161
+
162
+ // The stale check. The selection's line indices have meaning only against
163
+ // the exact diff the pane rendered; a file that changed since makes them
164
+ // point at different lines, so nothing is emitted, let alone applied.
165
+ const diff = await io.layerDiff(path, layer)
166
+ if (sha1Hex(diff) !== diffSha) {
167
+ return { ok: false, failure: 'stale', error: `${path} changed since the diff was loaded; nothing was applied` }
168
+ }
169
+
170
+ const file = parsePatch(diff)
171
+ // No hunk at all (empty or binary diff, with a sha that matches): there is
172
+ // nothing to select, which is the empty selection's no-op, not an error.
173
+ if (file === null) return { ok: true }
174
+ // Hunk line indices restart per hunk, so "line 1" of a two-hunk diff names a
175
+ // line in EACH hunk. Full context produces one hunk; anything else must be
176
+ // refused whole rather than applied to the wrong lines.
177
+ if (file.hunks.length > 1) {
178
+ return {
179
+ ok: false, failure: 'invalid',
180
+ error: `the diff for ${path} carries ${file.hunks.length} hunks; block operations need the single full-context hunk`,
181
+ }
182
+ }
183
+
184
+ const patch = emitPatch(file, lineSelector(lines), appliesInReverse(mode))
185
+ // An empty selection is not an error, and `git apply` rejects a patch with
186
+ // no hunks — so "nothing selected" must never reach git at all.
187
+ if (patch.length === 0) return { ok: true }
188
+
189
+ // The patch travels by tmpfile (`git()` spawns with `stdin: 'ignore'`), as
190
+ // the LAST argument of the apply spelling chosen above. Deleted in the
191
+ // finally whatever happens, so a refusal never litters the temp dir.
192
+ const patchFile = await io.writePatch(patch)
193
+ try {
194
+ const check = await io.git(cwd, [...argv, '--check', patchFile])
195
+ if (check.exitCode !== 0) return refusal(check)
196
+ const applied = await io.git(cwd, [...argv, patchFile])
197
+ if (applied.exitCode !== 0) return refusal(applied)
198
+ return { ok: true }
199
+ } finally {
200
+ await io.dropPatch(patchFile).catch(() => {
201
+ // Cleanup must never mask the result it follows; a tmpfile that is
202
+ // already gone has done its job.
203
+ })
204
+ }
205
+ }
206
+
207
+ /** A refused apply: git's own message verbatim, classified for the banner. */
208
+ function refusal(run: GitRun): ApplyBlocksResult {
209
+ const failure = classifyFailure(run.exitCode, run.stderr, run.stdout)
210
+ const error = (run.stderr || run.stdout).trim().slice(-1000)
211
+ // `classifyFailure` answers null only for a zero exit, which is not a
212
+ // refusal and cannot reach here. The key is omitted rather than sent as
213
+ // null anyway, because the gateway's payloads carry no empty fields.
214
+ return failure === null ? { ok: false, error } : { ok: false, failure, error }
215
+ }