@quandev104/pi-style 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 +30 -0
- package/README.md +10 -6
- package/dist/extensions/pi-style.js +3939 -1431
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-authorization.ts +6 -3
- package/extension-src/pi-style/domain/config-normalization.ts +21 -5
- package/extension-src/pi-style/domain/config-presets.ts +1 -1
- package/extension-src/pi-style/domain/config-types.ts +7 -3
- package/extension-src/pi-style/domain/theme.ts +6 -1
- package/extension-src/pi-style/features/editor/index.ts +169 -27
- package/extension-src/pi-style/features/messages/index.ts +66 -0
- package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +154 -130
- package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
- package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
- package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
- package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
- package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +32 -11
- package/extension-src/pi-style/pi/compatibility-probe.ts +341 -205
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +21 -3
- package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
- package/extension-src/pi-style/shared/box.ts +8 -4
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +9 -9
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,1960 @@
|
|
|
1
|
+
// Git semantic view renderer (Phase 8A).
|
|
2
|
+
//
|
|
3
|
+
// Bash `git status` / `git diff --stat` / short `git log` results render as a
|
|
4
|
+
// boxless compact card in the call panel, mirroring the ls/find/grep tree path
|
|
5
|
+
// (see bash.ts, which owns the per-call registry and raw-shell fallback).
|
|
6
|
+
//
|
|
7
|
+
// Every parser is fail-closed (ADR 0005): on any ambiguity it returns null and
|
|
8
|
+
// the boxed command/response shell renders the raw output unchanged. Only
|
|
9
|
+
// values git's output actually carries are shown — `git diff --stat` bars are
|
|
10
|
+
// scaled, so per-file rows show the exact `| N` change count instead of a
|
|
11
|
+
// guessed +/− split, and the exact +/− totals come from the summary line.
|
|
12
|
+
|
|
13
|
+
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
15
|
+
import {
|
|
16
|
+
type BoxTheme,
|
|
17
|
+
boxBlankLine,
|
|
18
|
+
boxLabeledBorder,
|
|
19
|
+
boxWidth,
|
|
20
|
+
dimLine,
|
|
21
|
+
renderBoxedToolResult,
|
|
22
|
+
} from "../../../shared/box.js";
|
|
23
|
+
import { formatElapsedMs } from "../../../shared/elapsed.js";
|
|
24
|
+
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
25
|
+
import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
|
|
26
|
+
import { parseSimpleBashCommand } from "./command-shape.js";
|
|
27
|
+
import { pluralForm, TREE_INDENT } from "./output-tree.js";
|
|
28
|
+
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
29
|
+
import type { BoxedToolContext } from "./shared.js";
|
|
30
|
+
|
|
31
|
+
// ── Classification ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** State-change git commands that render a boxless summary card (Phase 8C):
|
|
34
|
+
* `commit`/`push`/`pull`/`fetch` (8C-1) plus `switch`/`checkout`/`add`/
|
|
35
|
+
* `restore`/`reset`/`merge`/`rebase` (8C-2). Each surfaces a different shape;
|
|
36
|
+
* the parsers are fail-closed and the renderer switches on `command`. */
|
|
37
|
+
export type GitActionCommand =
|
|
38
|
+
| "commit"
|
|
39
|
+
| "push"
|
|
40
|
+
| "pull"
|
|
41
|
+
| "fetch"
|
|
42
|
+
| "switch"
|
|
43
|
+
| "checkout"
|
|
44
|
+
| "add"
|
|
45
|
+
| "restore"
|
|
46
|
+
| "reset"
|
|
47
|
+
| "merge"
|
|
48
|
+
| "rebase";
|
|
49
|
+
|
|
50
|
+
export type GitSemanticClass =
|
|
51
|
+
| { readonly kind: "status"; readonly short: boolean }
|
|
52
|
+
| { readonly kind: "diff-stat" }
|
|
53
|
+
| { readonly kind: "log" }
|
|
54
|
+
| { readonly kind: "show-stat" }
|
|
55
|
+
| { readonly kind: "diff"; readonly show: boolean }
|
|
56
|
+
| { readonly kind: "action"; readonly command: GitActionCommand };
|
|
57
|
+
|
|
58
|
+
const GIT_SHORT_STATUS_FLAGS = new Set(["-s", "--short", "--porcelain"]);
|
|
59
|
+
const GIT_DIFF_FORMAT_REJECT = new Set([
|
|
60
|
+
"-p",
|
|
61
|
+
"--patch",
|
|
62
|
+
"--numstat",
|
|
63
|
+
"--shortstat",
|
|
64
|
+
"--dirstat",
|
|
65
|
+
"--summary",
|
|
66
|
+
"--name-only",
|
|
67
|
+
"--name-status",
|
|
68
|
+
"--raw",
|
|
69
|
+
"--word-diff",
|
|
70
|
+
]);
|
|
71
|
+
/** Flags that switch `git diff` patch output to a non-line-based or summary
|
|
72
|
+
* shape we cannot feed to the adaptive diff component (ADR 0005: fail-closed). */
|
|
73
|
+
const GIT_DIFF_PATCH_REJECT = new Set([
|
|
74
|
+
"--numstat",
|
|
75
|
+
"--shortstat",
|
|
76
|
+
"--dirstat",
|
|
77
|
+
"--summary",
|
|
78
|
+
"--name-only",
|
|
79
|
+
"--name-status",
|
|
80
|
+
"--raw",
|
|
81
|
+
"--word-diff",
|
|
82
|
+
"--binary",
|
|
83
|
+
"--no-patch",
|
|
84
|
+
"-s",
|
|
85
|
+
"--patch-with-stat",
|
|
86
|
+
"--patch-with-raw",
|
|
87
|
+
]);
|
|
88
|
+
/** `git show` (plain patch output) additionally rejects `--stat` (commit + stat
|
|
89
|
+
* is a different shape) and commit-format flags that change the header. */
|
|
90
|
+
const GIT_SHOW_REJECT = new Set([...GIT_DIFF_PATCH_REJECT, "--stat", "--oneline", "--format", "--pretty"]);
|
|
91
|
+
/** `git show --stat` (commit header + stat block) rejects any other
|
|
92
|
+
* format-changing flag that would alter that shape: `-p`/`--patch` append a
|
|
93
|
+
* patch, the rest are alternate stat/format/name shapes (ADR 0005). */
|
|
94
|
+
const GIT_SHOW_STAT_REJECT = new Set([...GIT_DIFF_PATCH_REJECT, "-p", "--patch", "--oneline"]);
|
|
95
|
+
const GIT_LOG_FORMAT_REJECT = new Set([
|
|
96
|
+
"-p",
|
|
97
|
+
"--patch",
|
|
98
|
+
"--stat",
|
|
99
|
+
"--numstat",
|
|
100
|
+
"--shortstat",
|
|
101
|
+
"--dirstat",
|
|
102
|
+
"--summary",
|
|
103
|
+
"--name-only",
|
|
104
|
+
"--name-status",
|
|
105
|
+
"--raw",
|
|
106
|
+
"--graph",
|
|
107
|
+
"--format",
|
|
108
|
+
"--pretty",
|
|
109
|
+
"--word-diff",
|
|
110
|
+
"--color",
|
|
111
|
+
"--show-signature",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** `git commit` flags that change the output shape: `-v` appends the diff,
|
|
115
|
+
* `-p`/`--patch` open an editor with a patch, `-i`/`--interactive` are
|
|
116
|
+
* interactive, `--porcelain`/`--dry-run` swap to a different report
|
|
117
|
+
* (ADR 0005: fail-closed). */
|
|
118
|
+
const GIT_COMMIT_REJECT = new Set([
|
|
119
|
+
"-v",
|
|
120
|
+
"--verbose",
|
|
121
|
+
"-p",
|
|
122
|
+
"--patch",
|
|
123
|
+
"-i",
|
|
124
|
+
"--interactive",
|
|
125
|
+
"--porcelain",
|
|
126
|
+
"--dry-run",
|
|
127
|
+
]);
|
|
128
|
+
/** `git push` flags that change the output shape: `--porcelain` is machine
|
|
129
|
+
* format, `-v`/`--verbose` add `Pushing to`/`= [up to date]` lines, and
|
|
130
|
+
* `--dry-run` reports what would happen without doing it (ADR 0005). */
|
|
131
|
+
const GIT_PUSH_REJECT = new Set(["--porcelain", "-v", "--verbose", "--dry-run", "-n"]);
|
|
132
|
+
/** `git pull` flags that change the output shape: `-v`/`--verbose` add fetch
|
|
133
|
+
* chatter, `--rebase` produces a rebase-shaped report instead of a merge
|
|
134
|
+
* summary (ADR 0005). */
|
|
135
|
+
const GIT_PULL_REJECT = new Set(["-v", "--verbose", "--rebase"]);
|
|
136
|
+
/** `git fetch` flags that change the output shape: `-v`/`--verbose` add
|
|
137
|
+
* `= [up to date]` per-ref chatter and `--dry-run` reports without fetching
|
|
138
|
+
* (ADR 0005). */
|
|
139
|
+
const GIT_FETCH_REJECT = new Set(["-v", "--verbose", "--dry-run"]);
|
|
140
|
+
/** `git switch`/`checkout` flags that change the output shape: `-p`/`--patch`
|
|
141
|
+
* open an interactive hunk picker, `-i`/`--interactive` is the classic checkout
|
|
142
|
+
* TUI, and `--orphan` swaps the `Switched to …` line for a creation report
|
|
143
|
+
* (ADR 0005). `-m` (merge on switch) is not rejected here — its clean output
|
|
144
|
+
* still parses, and the merge-rows shape fails closed in the parser. */
|
|
145
|
+
const GIT_SWITCH_REJECT = new Set(["-p", "--patch", "-i", "--interactive", "--orphan"]);
|
|
146
|
+
/** `git add`/`restore` flags that change the output shape: `-p`/`--patch` and
|
|
147
|
+
* `-i`/`--interactive` open hunk/TUI pickers, and `-v`/`--verbose` list every
|
|
148
|
+
* staged path instead of staying silent (ADR 0005). */
|
|
149
|
+
const GIT_ADD_REJECT = new Set(["-p", "--patch", "-i", "--interactive", "-v", "--verbose"]);
|
|
150
|
+
/** `git reset` flags that change the output shape: `-p`/`--patch` opens an
|
|
151
|
+
* interactive hunk picker. The mode flags (`--soft`/`--mixed`/`--hard`) are
|
|
152
|
+
* the shapes the parser reads, so they stay classified (ADR 0005). */
|
|
153
|
+
const GIT_RESET_REJECT = new Set(["-p", "--patch"]);
|
|
154
|
+
/** `git merge` flags that change the output shape: `-v`/`--verbose` append the
|
|
155
|
+
* per-file diff. `--squash`/`--abort`/`--continue` produce different reports
|
|
156
|
+
* that fail closed in the parser (ADR 0005). */
|
|
157
|
+
const GIT_MERGE_REJECT = new Set(["-v", "--verbose"]);
|
|
158
|
+
/** `git rebase` flags that change the output shape entirely: `-i`/
|
|
159
|
+
* `--interactive` opens the commit-list editor and `-x`/`--exec` runs a shell
|
|
160
|
+
* command per commit, swapping the single success line for a different report
|
|
161
|
+
* (ADR 0005). */
|
|
162
|
+
const GIT_REBASE_REJECT = new Set(["-i", "--interactive", "-x", "--exec"]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Classify a bash command for git semantic rendering, or null to keep the
|
|
166
|
+
* boxed shell. Only porcelain commands with simple output shapes are eligible
|
|
167
|
+
* (`git status`, `git diff --stat`, `git log`); `git -C …`, aliases, plumbing
|
|
168
|
+
* (`cat-file`, `rev-parse`, `for-each-ref`), format-changing flags, and any
|
|
169
|
+
* pipe/redirect fall back raw (ADR 0005).
|
|
170
|
+
*/
|
|
171
|
+
export function classifyGitCommand(command: string): GitSemanticClass | null {
|
|
172
|
+
const shape = parseSimpleBashCommand(command);
|
|
173
|
+
if (!shape) return null;
|
|
174
|
+
const rest = shape.tokens;
|
|
175
|
+
if ((rest[0] ?? "").split("/").pop() !== "git") return null;
|
|
176
|
+
const args = rest.slice(1);
|
|
177
|
+
if (args.length === 0) return null;
|
|
178
|
+
const sub = args[0] ?? "";
|
|
179
|
+
|
|
180
|
+
if (sub === "status") {
|
|
181
|
+
// `-z`/`--porcelain=v2` switch the output format entirely; the v1 short
|
|
182
|
+
// parser cannot read them.
|
|
183
|
+
if (args.some((arg) => arg === "-z" || arg === "--null" || arg.startsWith("--porcelain=v2"))) return null;
|
|
184
|
+
const short =
|
|
185
|
+
args.some((arg) => GIT_SHORT_STATUS_FLAGS.has(arg) || /^-[sS][a-zA-Z]*$/.test(arg)) ||
|
|
186
|
+
args.some((arg) => arg.startsWith("--porcelain=v1"));
|
|
187
|
+
return { kind: "status", short };
|
|
188
|
+
}
|
|
189
|
+
if (sub === "diff") {
|
|
190
|
+
const hasStat = args.some((arg) => arg === "--stat" || arg.startsWith("--stat="));
|
|
191
|
+
if (hasStat) {
|
|
192
|
+
if (
|
|
193
|
+
args.some(
|
|
194
|
+
(arg) => GIT_DIFF_FORMAT_REJECT.has(arg) || arg.startsWith("--format=") || arg.startsWith("--pretty="),
|
|
195
|
+
)
|
|
196
|
+
) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return { kind: "diff-stat" };
|
|
200
|
+
}
|
|
201
|
+
// Plain `git diff` (patch output) renders as a boxed adaptive diff. Reject
|
|
202
|
+
// format-changing flags; `-p`/`--patch` is the default patch shape and stays.
|
|
203
|
+
if (args.some((arg) => GIT_DIFF_PATCH_REJECT.has(arg) || arg.startsWith("--word-diff="))) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return { kind: "diff", show: false };
|
|
207
|
+
}
|
|
208
|
+
if (sub === "show") {
|
|
209
|
+
const hasStat = args.some((arg) => arg === "--stat" || arg.startsWith("--stat="));
|
|
210
|
+
if (hasStat) {
|
|
211
|
+
// `git show --stat` = commit header + stat block → a diff-stat-shaped
|
|
212
|
+
// card. Reject any other format-changing flag (a patch, numstat,
|
|
213
|
+
// name-only, format override, …). `-p`/`--patch` append a patch; combined
|
|
214
|
+
// short clusters containing `p` (e.g. `-sp`) do too.
|
|
215
|
+
if (
|
|
216
|
+
args.some(
|
|
217
|
+
(arg) =>
|
|
218
|
+
GIT_SHOW_STAT_REJECT.has(arg) ||
|
|
219
|
+
/^-[A-Za-z]*p[A-Za-z]*$/.test(arg) ||
|
|
220
|
+
arg.startsWith("--word-diff=") ||
|
|
221
|
+
arg.startsWith("--format=") ||
|
|
222
|
+
arg.startsWith("--pretty="),
|
|
223
|
+
)
|
|
224
|
+
) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return { kind: "show-stat" };
|
|
228
|
+
}
|
|
229
|
+
// Plain `git show` (patch output) renders as a boxed adaptive diff. Reject
|
|
230
|
+
// format-changing flags; `-p`/`--patch` is the default patch shape and stays.
|
|
231
|
+
if (
|
|
232
|
+
args.some(
|
|
233
|
+
(arg) =>
|
|
234
|
+
GIT_SHOW_REJECT.has(arg) ||
|
|
235
|
+
arg.startsWith("--word-diff=") ||
|
|
236
|
+
arg.startsWith("--format=") ||
|
|
237
|
+
arg.startsWith("--pretty="),
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return { kind: "diff", show: true };
|
|
243
|
+
}
|
|
244
|
+
if (sub === "log") {
|
|
245
|
+
if (
|
|
246
|
+
args.some((arg) => GIT_LOG_FORMAT_REJECT.has(arg) || arg.startsWith("--format=") || arg.startsWith("--pretty="))
|
|
247
|
+
) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return { kind: "log" };
|
|
251
|
+
}
|
|
252
|
+
if (sub === "commit") {
|
|
253
|
+
if (args.some((arg) => GIT_COMMIT_REJECT.has(arg) || /^-[A-Za-z]*[vpi][A-Za-z]*$/.test(arg))) return null;
|
|
254
|
+
return { kind: "action", command: "commit" };
|
|
255
|
+
}
|
|
256
|
+
if (sub === "push") {
|
|
257
|
+
if (args.some((arg) => GIT_PUSH_REJECT.has(arg) || /^-[A-Za-z]*[vn][A-Za-z]*$/.test(arg))) return null;
|
|
258
|
+
return { kind: "action", command: "push" };
|
|
259
|
+
}
|
|
260
|
+
if (sub === "pull") {
|
|
261
|
+
if (args.some((arg) => GIT_PULL_REJECT.has(arg) || /^-[A-Za-z]*v[A-Za-z]*$/.test(arg))) return null;
|
|
262
|
+
return { kind: "action", command: "pull" };
|
|
263
|
+
}
|
|
264
|
+
if (sub === "fetch") {
|
|
265
|
+
if (args.some((arg) => GIT_FETCH_REJECT.has(arg) || /^-[A-Za-z]*v[A-Za-z]*$/.test(arg))) return null;
|
|
266
|
+
return { kind: "action", command: "fetch" };
|
|
267
|
+
}
|
|
268
|
+
if (sub === "switch" || sub === "checkout") {
|
|
269
|
+
// Reject interactive patch/TUI and orphan; `-m` stays (clean output parses,
|
|
270
|
+
// merge-rows shape fails closed). Short clusters containing `p`/`i` cover
|
|
271
|
+
// `-p`/`-i` bundled with other flags.
|
|
272
|
+
if (args.some((arg) => GIT_SWITCH_REJECT.has(arg) || /^-[A-Za-z]*[pi][A-Za-z]*$/.test(arg))) return null;
|
|
273
|
+
return { kind: "action", command: sub };
|
|
274
|
+
}
|
|
275
|
+
if (sub === "add" || sub === "restore") {
|
|
276
|
+
// Reject interactive patch/TUI and verbose; `-A`/`-u`/`-f`/`-S`/`-W` stay.
|
|
277
|
+
if (args.some((arg) => GIT_ADD_REJECT.has(arg) || /^-[A-Za-z]*[piv][A-Za-z]*$/.test(arg))) return null;
|
|
278
|
+
return { kind: "action", command: sub };
|
|
279
|
+
}
|
|
280
|
+
if (sub === "reset") {
|
|
281
|
+
// Reject interactive patch; the mode flags (`--soft`/`--mixed`/`--hard`)
|
|
282
|
+
// are the shapes the parser reads.
|
|
283
|
+
if (args.some((arg) => GIT_RESET_REJECT.has(arg) || /^-[A-Za-z]*p[A-Za-z]*$/.test(arg))) return null;
|
|
284
|
+
return { kind: "action", command: "reset" };
|
|
285
|
+
}
|
|
286
|
+
if (sub === "merge") {
|
|
287
|
+
// Reject verbose; `--squash`/`--abort`/`--continue` fail closed in the parser.
|
|
288
|
+
if (args.some((arg) => GIT_MERGE_REJECT.has(arg) || /^-[A-Za-z]*v[A-Za-z]*$/.test(arg))) return null;
|
|
289
|
+
return { kind: "action", command: "merge" };
|
|
290
|
+
}
|
|
291
|
+
if (sub === "rebase") {
|
|
292
|
+
// Reject interactive/exec; `--abort`/`--continue`/`--skip` fail closed.
|
|
293
|
+
if (
|
|
294
|
+
args.some(
|
|
295
|
+
(arg) => GIT_REBASE_REJECT.has(arg) || arg.startsWith("--exec=") || /^-[A-Za-z]*[ix][A-Za-z]*$/.test(arg),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
return null;
|
|
299
|
+
return { kind: "action", command: "rebase" };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Parsed shapes ───────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export interface GitStatusFile {
|
|
307
|
+
/** Index (staged) status char: `M`/`A`/`D`/`R`/`C`/`T`/`U`/`?`/`!` or space. */
|
|
308
|
+
readonly x: string;
|
|
309
|
+
/** Worktree (unstaged) status char, or space. */
|
|
310
|
+
readonly y: string;
|
|
311
|
+
/** Display path; renames carry `old -> new`. */
|
|
312
|
+
readonly path: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface GitStatusParsed {
|
|
316
|
+
readonly kind: "status";
|
|
317
|
+
readonly branch?: string;
|
|
318
|
+
readonly ahead?: number;
|
|
319
|
+
readonly behind?: number;
|
|
320
|
+
readonly diverged?: boolean;
|
|
321
|
+
readonly files: readonly GitStatusFile[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface GitDiffStatFile {
|
|
325
|
+
readonly path: string;
|
|
326
|
+
/** Exact changed-line count from the `| N` column (absent for binary files). */
|
|
327
|
+
readonly changes?: number;
|
|
328
|
+
readonly binary?: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Per-file stat summary shared by `git diff --stat` and `git show --stat`
|
|
332
|
+
* (the show-stat card adds a commit header on top of these rows). */
|
|
333
|
+
interface DiffStatSummary {
|
|
334
|
+
readonly files: readonly GitDiffStatFile[];
|
|
335
|
+
readonly filesChanged?: number;
|
|
336
|
+
readonly insertions?: number;
|
|
337
|
+
readonly deletions?: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface GitDiffStatParsed extends DiffStatSummary {
|
|
341
|
+
readonly kind: "diff-stat";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** `git show --stat`: full-format commit header (hash + first message-line
|
|
345
|
+
* subject) followed by the same per-file stat block as `git diff --stat`. */
|
|
346
|
+
export interface GitShowStatParsed extends DiffStatSummary {
|
|
347
|
+
readonly kind: "show-stat";
|
|
348
|
+
/** Full commit hash from the `commit <hash>` header (shortened for display). */
|
|
349
|
+
readonly hash: string;
|
|
350
|
+
readonly subject: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface GitLogCommit {
|
|
354
|
+
readonly hash: string;
|
|
355
|
+
readonly refs?: string;
|
|
356
|
+
readonly subject: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface GitLogParsed {
|
|
360
|
+
readonly kind: "log";
|
|
361
|
+
readonly commits: readonly GitLogCommit[];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface GitDiffFile {
|
|
365
|
+
/** Display path (renames carry `old => new`). */
|
|
366
|
+
readonly path: string;
|
|
367
|
+
readonly status?: "added" | "deleted" | "renamed" | "modified";
|
|
368
|
+
readonly binary?: boolean;
|
|
369
|
+
readonly additions: number;
|
|
370
|
+
readonly removals: number;
|
|
371
|
+
/** Normalized edit-format diff body for `AdaptiveDiffComponent` (empty for binary). */
|
|
372
|
+
readonly body: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface GitDiffParsed {
|
|
376
|
+
readonly kind: "diff";
|
|
377
|
+
readonly show: boolean;
|
|
378
|
+
/** `git show`: short commit hash from the commit header. */
|
|
379
|
+
readonly hash?: string;
|
|
380
|
+
/** `git show`: first message-line subject. */
|
|
381
|
+
readonly subject?: string;
|
|
382
|
+
readonly files: readonly GitDiffFile[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** `git commit` / `push` / `pull` / `fetch` / `switch` / `checkout` / `add` /
|
|
386
|
+
* `restore` / `reset` / `merge` / `rebase` state-change results. Each command
|
|
387
|
+
* surfaces a different subset of fields; the renderer switches on `command`.
|
|
388
|
+
* Stat summaries (commit / pull fast-forward / merge) reuse the diff-stat
|
|
389
|
+
* shape so the same `N files changed · +A -D` line and `├─/└─` rows render
|
|
390
|
+
* verbatim. */
|
|
391
|
+
export interface GitActionParsed extends DiffStatSummary {
|
|
392
|
+
readonly kind: "action";
|
|
393
|
+
readonly command: GitActionCommand;
|
|
394
|
+
/** `git commit` success: branch from `[<branch> <hash>]` (status line owns
|
|
395
|
+
* the `⎇ main` glyph, so this is not rendered). */
|
|
396
|
+
readonly branch?: string;
|
|
397
|
+
/** `git commit` success: short hash from `[<branch> <hash>]`; also
|
|
398
|
+
* `git reset --hard`: the `HEAD is now at <hash>` target. */
|
|
399
|
+
readonly hash?: string;
|
|
400
|
+
/** `git commit` success: commit subject line; also `git reset --hard`:
|
|
401
|
+
* the `HEAD is now at <hash> <subject>` subject. */
|
|
402
|
+
readonly subject?: string;
|
|
403
|
+
/** `git push` / `git fetch`: remote URL/path from the `To `/`From ` line. */
|
|
404
|
+
readonly remote?: string;
|
|
405
|
+
/** `git push` / `git fetch`: normalized ref-update rows (alignment spaces
|
|
406
|
+
* collapsed to single spaces). */
|
|
407
|
+
readonly refs?: readonly string[];
|
|
408
|
+
/** Informational status line: `nothing to commit`, `Everything up-to-date`,
|
|
409
|
+
* `Already up to date.`, `Fast-forward`, `no new refs`, `Merge made by the
|
|
410
|
+
* 'ort' strategy.`, or `completed, no output` for silent success
|
|
411
|
+
* (`add`/`restore`/`reset --soft`/`checkout -- <file>`). */
|
|
412
|
+
readonly status?: string;
|
|
413
|
+
/** `git pull` / `git merge` fast-forward: the `Updating <a>..<b>` range. */
|
|
414
|
+
readonly range?: string;
|
|
415
|
+
/** `git switch -c` / `git checkout -b`: the branch was created, not just
|
|
416
|
+
* switched to (controls the confirmation row wording). */
|
|
417
|
+
readonly created?: boolean;
|
|
418
|
+
/** `git reset` (mixed): status-marker + path rows from the
|
|
419
|
+
* `Unstaged changes after reset:` block (marker in `x`, `y` is space). */
|
|
420
|
+
readonly resetFiles?: readonly GitStatusFile[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export type GitParsedSemantic =
|
|
424
|
+
| GitStatusParsed
|
|
425
|
+
| GitDiffStatParsed
|
|
426
|
+
| GitShowStatParsed
|
|
427
|
+
| GitLogParsed
|
|
428
|
+
| GitDiffParsed
|
|
429
|
+
| GitActionParsed;
|
|
430
|
+
|
|
431
|
+
// ── git status parsers ──────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
/** Long-form section verbs → `(index, worktree)` status chars. */
|
|
434
|
+
const LONG_STATUS_VERBS: Readonly<Record<string, readonly [string, string]>> = {
|
|
435
|
+
"new file": ["A", " "],
|
|
436
|
+
modified: ["M", " "],
|
|
437
|
+
deleted: ["D", " "],
|
|
438
|
+
renamed: ["R", " "],
|
|
439
|
+
copied: ["C", " "],
|
|
440
|
+
typechange: ["T", " "],
|
|
441
|
+
"both modified": ["U", "U"],
|
|
442
|
+
"both added": ["A", "A"],
|
|
443
|
+
"both deleted": ["D", "D"],
|
|
444
|
+
"added by us": ["A", "U"],
|
|
445
|
+
"added by them": ["U", "A"],
|
|
446
|
+
"deleted by us": ["D", "U"],
|
|
447
|
+
"deleted by them": ["U", "D"],
|
|
448
|
+
unmerged: ["U", "U"],
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
type LongSection = "staged" | "unstaged" | "untracked" | "ignored" | "unmerged" | null;
|
|
452
|
+
|
|
453
|
+
const LONG_SECTION_HEADERS: Readonly<Record<string, LongSection>> = {
|
|
454
|
+
"Changes to be committed:": "staged",
|
|
455
|
+
"Changes not staged for commit:": "unstaged",
|
|
456
|
+
"Untracked files:": "untracked",
|
|
457
|
+
"Ignored files:": "ignored",
|
|
458
|
+
"Unmerged paths:": "unmerged",
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const LONG_STATUS_ENTRY = /^([a-z ]+?):\s+(.+)$/;
|
|
462
|
+
const LONG_BRANCH = /^On branch (.+)$/;
|
|
463
|
+
const LONG_DETACHED = /^HEAD detached at ([0-9a-f]+)/;
|
|
464
|
+
const LONG_AHEAD = /^Your branch is ahead of '(.*)' by (\d+) commit/;
|
|
465
|
+
const LONG_BEHIND = /^Your branch is behind '(.*)' by (\d+) commit/;
|
|
466
|
+
const LONG_DIVERGED = /^Your branch and '(.*)' have diverged/;
|
|
467
|
+
const LONG_DIVERGED_COUNTS = /^and have (\d+) and (\d+) different commits each/;
|
|
468
|
+
|
|
469
|
+
function parseGitStatusLong(text: string): GitStatusParsed | null {
|
|
470
|
+
const files: GitStatusFile[] = [];
|
|
471
|
+
let branch: string | undefined;
|
|
472
|
+
let ahead: number | undefined;
|
|
473
|
+
let behind: number | undefined;
|
|
474
|
+
let diverged = false;
|
|
475
|
+
let section: LongSection = null;
|
|
476
|
+
let divergedNext = false;
|
|
477
|
+
let sawContent = false;
|
|
478
|
+
|
|
479
|
+
for (const rawLine of text.split("\n")) {
|
|
480
|
+
const line = rawLine.trimEnd();
|
|
481
|
+
if (line === "") continue;
|
|
482
|
+
|
|
483
|
+
const branchMatch = LONG_BRANCH.exec(line);
|
|
484
|
+
if (branchMatch) {
|
|
485
|
+
sawContent = true;
|
|
486
|
+
branch = branchMatch[1];
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const detached = LONG_DETACHED.exec(line);
|
|
490
|
+
if (detached) {
|
|
491
|
+
sawContent = true;
|
|
492
|
+
branch = detached[1];
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const aheadMatch = LONG_AHEAD.exec(line);
|
|
496
|
+
if (aheadMatch) {
|
|
497
|
+
sawContent = true;
|
|
498
|
+
ahead = Number(aheadMatch[2]);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const behindMatch = LONG_BEHIND.exec(line);
|
|
502
|
+
if (behindMatch) {
|
|
503
|
+
sawContent = true;
|
|
504
|
+
behind = Number(behindMatch[2]);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const divergedMatch = LONG_DIVERGED.exec(line);
|
|
508
|
+
if (divergedMatch) {
|
|
509
|
+
sawContent = true;
|
|
510
|
+
diverged = true;
|
|
511
|
+
divergedNext = true;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (divergedNext) {
|
|
515
|
+
const counts = LONG_DIVERGED_COUNTS.exec(line);
|
|
516
|
+
if (!counts) return null;
|
|
517
|
+
sawContent = true;
|
|
518
|
+
ahead = Number(counts[1]);
|
|
519
|
+
behind = Number(counts[2]);
|
|
520
|
+
divergedNext = false;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const sectionHeader = LONG_SECTION_HEADERS[line];
|
|
525
|
+
if (sectionHeader !== undefined) {
|
|
526
|
+
sawContent = true;
|
|
527
|
+
section = sectionHeader;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// Hint lines (` (use "git add …" …)`, ` (fix conflicts …)`) and
|
|
531
|
+
// clean/empty-state markers.
|
|
532
|
+
if (/^\s{2}\(/.test(line)) continue;
|
|
533
|
+
if (
|
|
534
|
+
line.startsWith("no changes added to commit") ||
|
|
535
|
+
line === "nothing to commit, working tree clean" ||
|
|
536
|
+
line === "You have unmerged paths." ||
|
|
537
|
+
line === "No commits yet"
|
|
538
|
+
) {
|
|
539
|
+
sawContent = true;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (line.startsWith("Your branch is up to date with ")) {
|
|
543
|
+
sawContent = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (line.startsWith("\t")) {
|
|
548
|
+
const body = line.slice(1).trimStart();
|
|
549
|
+
if (!body) return null;
|
|
550
|
+
// Untracked/ignored entries are bare paths.
|
|
551
|
+
if (section === "untracked" || section === "ignored") {
|
|
552
|
+
const mark = section === "untracked" ? "?" : "!";
|
|
553
|
+
sawContent = true;
|
|
554
|
+
files.push({ x: mark, y: mark, path: body });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const verbMatch = LONG_STATUS_ENTRY.exec(body);
|
|
558
|
+
if (!verbMatch) return null;
|
|
559
|
+
const xy = LONG_STATUS_VERBS[verbMatch[1] ?? ""];
|
|
560
|
+
if (!xy) return null; // unknown verb (localized git, unexpected section)
|
|
561
|
+
const path = (verbMatch[2] ?? "").trim();
|
|
562
|
+
if (!path) return null;
|
|
563
|
+
sawContent = true;
|
|
564
|
+
if (section === "staged") files.push({ x: xy[0], y: " ", path });
|
|
565
|
+
else if (section === "unstaged") files.push({ x: " ", y: xy[0], path });
|
|
566
|
+
else files.push({ x: xy[0], y: xy[1], path });
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return null; // unrecognized non-tab line → localized/hostile output
|
|
571
|
+
}
|
|
572
|
+
if (divergedNext) return null;
|
|
573
|
+
if (!sawContent) return null;
|
|
574
|
+
return {
|
|
575
|
+
kind: "status",
|
|
576
|
+
files,
|
|
577
|
+
...(branch !== undefined ? { branch } : {}),
|
|
578
|
+
...(ahead !== undefined ? { ahead } : {}),
|
|
579
|
+
...(behind !== undefined ? { behind } : {}),
|
|
580
|
+
...(diverged ? { diverged: true } : {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const SHORT_STATUS_BRANCH = /^## (.+)$/;
|
|
585
|
+
const SHORT_STATUS_BRANCH_DETAIL = /^(.+?)(?:\.\.\.(.+?))?(?: \[([^\]]+)\])?$/;
|
|
586
|
+
const SHORT_STATUS_FILE = /^([ MADRCU?!])([ MADRCU?!]) (.*)$/;
|
|
587
|
+
|
|
588
|
+
function parseGitStatusShort(text: string): GitStatusParsed | null {
|
|
589
|
+
if (text.includes("\u0000")) return null; // `-z` NUL-separated format
|
|
590
|
+
const files: GitStatusFile[] = [];
|
|
591
|
+
let branch: string | undefined;
|
|
592
|
+
let ahead: number | undefined;
|
|
593
|
+
let behind: number | undefined;
|
|
594
|
+
let diverged = false;
|
|
595
|
+
let sawStatus = false;
|
|
596
|
+
|
|
597
|
+
for (const rawLine of text.split("\n")) {
|
|
598
|
+
const line = rawLine.trimEnd();
|
|
599
|
+
if (line === "") continue;
|
|
600
|
+
|
|
601
|
+
const branchLine = SHORT_STATUS_BRANCH.exec(line);
|
|
602
|
+
if (branchLine) {
|
|
603
|
+
sawStatus = true;
|
|
604
|
+
const detail = SHORT_STATUS_BRANCH_DETAIL.exec(branchLine[1] ?? "");
|
|
605
|
+
if (detail) {
|
|
606
|
+
branch = detail[1];
|
|
607
|
+
const bracket = detail[3];
|
|
608
|
+
if (bracket) {
|
|
609
|
+
const aheadM = /ahead (\d+)/.exec(bracket);
|
|
610
|
+
const behindM = /behind (\d+)/.exec(bracket);
|
|
611
|
+
if (aheadM) ahead = Number(aheadM[1]);
|
|
612
|
+
if (behindM) behind = Number(behindM[1]);
|
|
613
|
+
if (aheadM && behindM) diverged = true;
|
|
614
|
+
// `[gone]` and other bracket states carry no counts — ignored.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const fileMatch = SHORT_STATUS_FILE.exec(line);
|
|
621
|
+
if (!fileMatch) return null; // unrecognized line → hostile output
|
|
622
|
+
sawStatus = true;
|
|
623
|
+
files.push({ x: fileMatch[1] ?? "", y: fileMatch[2] ?? "", path: fileMatch[3] ?? "" });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!sawStatus && text.trim() !== "") return null;
|
|
627
|
+
return {
|
|
628
|
+
kind: "status",
|
|
629
|
+
files,
|
|
630
|
+
...(branch !== undefined ? { branch } : {}),
|
|
631
|
+
...(ahead !== undefined ? { ahead } : {}),
|
|
632
|
+
...(behind !== undefined ? { behind } : {}),
|
|
633
|
+
...(diverged ? { diverged: true } : {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseGitStatus(cls: GitSemanticClass, text: string): GitStatusParsed | null {
|
|
638
|
+
if (cls.kind !== "status") return null;
|
|
639
|
+
return cls.short ? parseGitStatusShort(text) : parseGitStatusLong(text);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── git diff --stat parser ──────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
const DIFF_STAT_SUMMARY = /^\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?$/;
|
|
645
|
+
const DIFF_STAT_FILE = /^(.*)\s+\|\s+(\d+)\s*.*$/;
|
|
646
|
+
const DIFF_STAT_BINARY = /^(.*)\s+\|\s+Bin\s+.*$/;
|
|
647
|
+
|
|
648
|
+
function parseGitDiffStat(text: string): GitDiffStatParsed | null {
|
|
649
|
+
const files: GitDiffStatFile[] = [];
|
|
650
|
+
let filesChanged: number | undefined;
|
|
651
|
+
let insertions: number | undefined;
|
|
652
|
+
let deletions: number | undefined;
|
|
653
|
+
let sawLine = false;
|
|
654
|
+
|
|
655
|
+
for (const rawLine of text.split("\n")) {
|
|
656
|
+
const line = rawLine.trimEnd();
|
|
657
|
+
if (line === "") continue;
|
|
658
|
+
sawLine = true;
|
|
659
|
+
|
|
660
|
+
const summary = DIFF_STAT_SUMMARY.exec(line);
|
|
661
|
+
if (summary) {
|
|
662
|
+
filesChanged = Number(summary[1]);
|
|
663
|
+
if (summary[2] !== undefined) insertions = Number(summary[2]);
|
|
664
|
+
if (summary[3] !== undefined) deletions = Number(summary[3]);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const binary = DIFF_STAT_BINARY.exec(line);
|
|
669
|
+
if (binary) {
|
|
670
|
+
const path = (binary[1] ?? "").trim();
|
|
671
|
+
if (!path) return null;
|
|
672
|
+
files.push({ path, binary: true });
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const file = DIFF_STAT_FILE.exec(line);
|
|
677
|
+
if (file) {
|
|
678
|
+
const path = (file[1] ?? "").trim();
|
|
679
|
+
if (!path) return null;
|
|
680
|
+
files.push({ path, changes: Number(file[2]) });
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return null; // unrecognized line (--numstat/-z output, stat=width oddities)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!sawLine) return { kind: "diff-stat", files }; // empty diff → no changes
|
|
688
|
+
if (files.length === 0 && filesChanged === undefined) return null;
|
|
689
|
+
return {
|
|
690
|
+
kind: "diff-stat",
|
|
691
|
+
files,
|
|
692
|
+
...(filesChanged !== undefined ? { filesChanged } : {}),
|
|
693
|
+
...(insertions !== undefined ? { insertions } : {}),
|
|
694
|
+
...(deletions !== undefined ? { deletions } : {}),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── git log parser ──────────────────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
const LOG_COMMIT_LINE = /^commit ([0-9a-f]{4,40})(?: \((.*)\))?$/;
|
|
701
|
+
const LOG_ONELINE = /^([0-9a-f]{4,40})(?: \(([^)]*)\))?\s*(.*)$/;
|
|
702
|
+
const LOG_HEADER_LINE = /^(?:Author|Date|Merge):/;
|
|
703
|
+
const LOG_MESSAGE_LINE = /^\s{4}(.*)$/;
|
|
704
|
+
|
|
705
|
+
function parseGitLog(text: string): GitLogParsed | null {
|
|
706
|
+
const lines = text
|
|
707
|
+
.split("\n")
|
|
708
|
+
.map((line) => line.trimEnd())
|
|
709
|
+
.filter((line) => line.length > 0);
|
|
710
|
+
if (lines.length === 0) return { kind: "log", commits: [] };
|
|
711
|
+
|
|
712
|
+
// Oneline format (`git log --oneline`): every line is `hash [refs] subject`.
|
|
713
|
+
if (lines.every((line) => LOG_ONELINE.test(line))) {
|
|
714
|
+
const commits = lines.map((line) => {
|
|
715
|
+
const match = LOG_ONELINE.exec(line);
|
|
716
|
+
const refs = match?.[2];
|
|
717
|
+
return {
|
|
718
|
+
hash: match?.[1] ?? "",
|
|
719
|
+
...(refs ? { refs } : {}),
|
|
720
|
+
subject: (match?.[3] ?? "").trim(),
|
|
721
|
+
};
|
|
722
|
+
});
|
|
723
|
+
return { kind: "log", commits };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Full format: `commit <hash> [refs]` blocks with Author/Date/Merge headers
|
|
727
|
+
// and a 4-space-indented message.
|
|
728
|
+
const commits: GitLogCommit[] = [];
|
|
729
|
+
let current: { hash: string; refs?: string; subject: string } | null = null;
|
|
730
|
+
for (const line of lines) {
|
|
731
|
+
const start = LOG_COMMIT_LINE.exec(line);
|
|
732
|
+
if (start) {
|
|
733
|
+
current = {
|
|
734
|
+
hash: start[1] ?? "",
|
|
735
|
+
...(start[2] ? { refs: start[2] } : {}),
|
|
736
|
+
subject: "",
|
|
737
|
+
};
|
|
738
|
+
commits.push(current);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (!current) return null;
|
|
742
|
+
if (LOG_HEADER_LINE.test(line)) continue;
|
|
743
|
+
const message = LOG_MESSAGE_LINE.exec(line);
|
|
744
|
+
if (message) {
|
|
745
|
+
if (current.subject === "") current.subject = (message[1] ?? "").trim();
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
return null; // unexpected line inside a commit block (patch/format output)
|
|
749
|
+
}
|
|
750
|
+
if (commits.length === 0) return null;
|
|
751
|
+
return { kind: "log", commits };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── git show --stat parser ───────────────────────────────────────────────────
|
|
755
|
+
// `git show --stat` = a full-format commit header (the same shape `parseGitLog`
|
|
756
|
+
// reads: `commit <hash>`, Author/Date/Merge, 4-space-indented message) followed
|
|
757
|
+
// by the same stat block `parseGitDiffStat` reads. The header is consumed first
|
|
758
|
+
// (subject = first message line), then the remainder is handed to the diff-stat
|
|
759
|
+
// line parser; any hostile line fails closed → raw boxed shell (ADR 0005).
|
|
760
|
+
|
|
761
|
+
function parseGitShowStat(text: string): GitShowStatParsed | null {
|
|
762
|
+
const lines = String(text ?? "")
|
|
763
|
+
.replace(/\r/g, "")
|
|
764
|
+
.split("\n")
|
|
765
|
+
.map((line) => line.trimEnd());
|
|
766
|
+
let i = 0;
|
|
767
|
+
while (i < lines.length && (lines[i] ?? "") === "") i++; // skip leading blanks
|
|
768
|
+
if (i >= lines.length) return null;
|
|
769
|
+
const commitMatch = LOG_COMMIT_LINE.exec(lines[i] ?? "");
|
|
770
|
+
if (!commitMatch) return null; // not a `git show` commit header
|
|
771
|
+
const hash = commitMatch[1] ?? "";
|
|
772
|
+
i++;
|
|
773
|
+
let subject = "";
|
|
774
|
+
// Consume the commit header: Author/Date/Merge lines, blank lines, and the
|
|
775
|
+
// 4-space-indented message block (subject = first message line). The first
|
|
776
|
+
// line that is none of these begins the stat block. Stat rows carry only a
|
|
777
|
+
// single leading space, so they never match the 4-space message pattern.
|
|
778
|
+
while (i < lines.length) {
|
|
779
|
+
const line = lines[i] ?? "";
|
|
780
|
+
if (line === "") {
|
|
781
|
+
i++;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (LOG_HEADER_LINE.test(line)) {
|
|
785
|
+
i++;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const message = LOG_MESSAGE_LINE.exec(line);
|
|
789
|
+
if (message) {
|
|
790
|
+
if (subject === "") subject = (message[1] ?? "").trim();
|
|
791
|
+
i++;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
// The remainder is the diff-stat block (empty for a commit with no file
|
|
797
|
+
// changes). Reuse the diff-stat line parser, fail-closed on hostile lines.
|
|
798
|
+
const stat = parseGitDiffStat(lines.slice(i).join("\n"));
|
|
799
|
+
if (!stat) return null;
|
|
800
|
+
return {
|
|
801
|
+
kind: "show-stat",
|
|
802
|
+
hash,
|
|
803
|
+
subject,
|
|
804
|
+
files: stat.files,
|
|
805
|
+
...(stat.filesChanged !== undefined ? { filesChanged: stat.filesChanged } : {}),
|
|
806
|
+
...(stat.insertions !== undefined ? { insertions: stat.insertions } : {}),
|
|
807
|
+
...(stat.deletions !== undefined ? { deletions: stat.deletions } : {}),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── git diff / git show parser ──────────────────────────────────────────────
|
|
812
|
+
// Splits unified diff output into per-file chunks, strips file headers and
|
|
813
|
+
// hunk headers, and converts content lines into the numbered `<prefix> <num>
|
|
814
|
+
// <content>` shape `buildSplitRows` (the `AdaptiveDiffComponent` input) reads.
|
|
815
|
+
// Every ambiguity returns null → the boxed Bash shell renders raw (ADR 0005).
|
|
816
|
+
|
|
817
|
+
const DIFF_GIT_HEADER = /^diff --git a\/(.*) b\/(.*)$/;
|
|
818
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
819
|
+
const NEW_FILE_MODE = /^new file mode /;
|
|
820
|
+
const DELETED_FILE_MODE = /^deleted file mode /;
|
|
821
|
+
|
|
822
|
+
/** Strip the `a/` / `b/` prefix (and surrounding quotes) from a `--- `/`+++ ` path. */
|
|
823
|
+
function stripDiffPathPrefix(rawPath: string): string {
|
|
824
|
+
let path = rawPath;
|
|
825
|
+
if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
|
|
826
|
+
if (path.startsWith("a/")) return path.slice(2);
|
|
827
|
+
if (path.startsWith("b/")) return path.slice(2);
|
|
828
|
+
return path;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function parseDiffChunk(chunk: readonly string[]): GitDiffFile | null {
|
|
832
|
+
const header = chunk[0] ?? "";
|
|
833
|
+
const dgMatch = DIFF_GIT_HEADER.exec(header);
|
|
834
|
+
if (!dgMatch) return null;
|
|
835
|
+
const dgOld = dgMatch[1] ?? undefined;
|
|
836
|
+
const dgNew = dgMatch[2] ?? undefined;
|
|
837
|
+
|
|
838
|
+
let oldPath: string | undefined;
|
|
839
|
+
let newPath: string | undefined;
|
|
840
|
+
let status: GitDiffFile["status"];
|
|
841
|
+
let binary = false;
|
|
842
|
+
let renameDetected = false;
|
|
843
|
+
const bodyLines: string[] = [];
|
|
844
|
+
let additions = 0;
|
|
845
|
+
let removals = 0;
|
|
846
|
+
let inHunk = false;
|
|
847
|
+
let oldLine = 0;
|
|
848
|
+
let newLine = 0;
|
|
849
|
+
|
|
850
|
+
for (let i = 1; i < chunk.length; i++) {
|
|
851
|
+
const line = chunk[i] ?? "";
|
|
852
|
+
|
|
853
|
+
const hunk = HUNK_HEADER.exec(line);
|
|
854
|
+
if (hunk) {
|
|
855
|
+
oldLine = Number(hunk[1] ?? 0);
|
|
856
|
+
newLine = Number(hunk[2] ?? 0);
|
|
857
|
+
inHunk = true;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!inHunk) {
|
|
862
|
+
if (line === "") continue; // section separator
|
|
863
|
+
if (line.startsWith("index ")) continue;
|
|
864
|
+
if (NEW_FILE_MODE.test(line)) {
|
|
865
|
+
status = "added";
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (DELETED_FILE_MODE.test(line)) {
|
|
869
|
+
status = "deleted";
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (line.startsWith("old mode ") || line.startsWith("new mode ")) continue;
|
|
873
|
+
if (line.startsWith("similarity index ") || line.startsWith("dissimilarity index ")) {
|
|
874
|
+
renameDetected = true;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (line.startsWith("rename from ")) {
|
|
878
|
+
oldPath = line.slice("rename from ".length);
|
|
879
|
+
renameDetected = true;
|
|
880
|
+
status = "renamed";
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (line.startsWith("rename to ")) {
|
|
884
|
+
newPath = line.slice("rename to ".length);
|
|
885
|
+
renameDetected = true;
|
|
886
|
+
status = "renamed";
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (line.startsWith("copy from ") || line.startsWith("copy to ")) continue;
|
|
890
|
+
if (line.startsWith("--- ")) {
|
|
891
|
+
const value = line.slice(4);
|
|
892
|
+
if (value !== "/dev/null") oldPath = stripDiffPathPrefix(value);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (line.startsWith("+++ ")) {
|
|
896
|
+
const value = line.slice(4);
|
|
897
|
+
if (value !== "/dev/null") newPath = stripDiffPathPrefix(value);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (line.startsWith("Binary files ") || line === "Binary files differ") {
|
|
901
|
+
binary = true;
|
|
902
|
+
const bm = line.match(/^Binary files (?:a\/(\S*) )?and (?:b\/(\S*) )?differ/);
|
|
903
|
+
if (bm) {
|
|
904
|
+
if (!oldPath && bm[1]) oldPath = bm[1];
|
|
905
|
+
if (!newPath && bm[2]) newPath = bm[2];
|
|
906
|
+
}
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (line.startsWith("GIT binary patch")) return null; // unparseable binary patch body
|
|
910
|
+
return null; // unrecognized header line → hostile/localized output
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// inside a hunk
|
|
914
|
+
if (line.startsWith("\\ No newline")) continue;
|
|
915
|
+
if (line.startsWith("+")) {
|
|
916
|
+
bodyLines.push(`+ ${newLine} ${line.slice(1)}`);
|
|
917
|
+
newLine++;
|
|
918
|
+
additions++;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (line.startsWith("-")) {
|
|
922
|
+
bodyLines.push(`- ${oldLine} ${line.slice(1)}`);
|
|
923
|
+
oldLine++;
|
|
924
|
+
removals++;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (line.startsWith(" ")) {
|
|
928
|
+
bodyLines.push(` ${oldLine} ${line.slice(1)}`);
|
|
929
|
+
oldLine++;
|
|
930
|
+
newLine++;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (line === "") {
|
|
934
|
+
// Blank context line whose leading space was stripped (trailing-ws
|
|
935
|
+
// safety): keep it as an empty context row so the diff stays aligned.
|
|
936
|
+
bodyLines.push(` ${oldLine} `);
|
|
937
|
+
oldLine++;
|
|
938
|
+
newLine++;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
return null; // unexpected line inside a hunk
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (!newPath) newPath = dgNew;
|
|
945
|
+
if (!oldPath) oldPath = dgOld;
|
|
946
|
+
let displayPath: string;
|
|
947
|
+
if (renameDetected && oldPath && newPath && oldPath !== newPath) {
|
|
948
|
+
displayPath = `${oldPath} => ${newPath}`;
|
|
949
|
+
} else {
|
|
950
|
+
displayPath = newPath ?? oldPath ?? "(unknown)";
|
|
951
|
+
}
|
|
952
|
+
if (!status) {
|
|
953
|
+
if (binary) status = "modified";
|
|
954
|
+
else if (oldPath && newPath) status = "modified";
|
|
955
|
+
else if (newPath && !oldPath) status = "added";
|
|
956
|
+
else if (oldPath && !newPath) status = "deleted";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return {
|
|
960
|
+
path: displayPath,
|
|
961
|
+
...(status ? { status } : {}),
|
|
962
|
+
...(binary ? { binary: true } : {}),
|
|
963
|
+
additions,
|
|
964
|
+
removals,
|
|
965
|
+
body: bodyLines.join("\n"),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function parseUnifiedDiff(text: string): GitDiffFile[] | null {
|
|
970
|
+
if (text === "") return []; // empty diff (no changes)
|
|
971
|
+
const lines = text.split("\n");
|
|
972
|
+
// Drop a single trailing empty line produced by the final newline.
|
|
973
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
974
|
+
const chunks: string[][] = [];
|
|
975
|
+
let current: string[] | null = null;
|
|
976
|
+
for (const line of lines) {
|
|
977
|
+
if (line.startsWith("diff --git ")) {
|
|
978
|
+
if (current) chunks.push(current);
|
|
979
|
+
current = [line];
|
|
980
|
+
} else if (current) {
|
|
981
|
+
current.push(line);
|
|
982
|
+
} else {
|
|
983
|
+
return null; // content before the first `diff --git` (unrecognized prefix)
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (current) chunks.push(current);
|
|
987
|
+
if (chunks.length === 0) return null;
|
|
988
|
+
const files: GitDiffFile[] = [];
|
|
989
|
+
for (const chunk of chunks) {
|
|
990
|
+
const file = parseDiffChunk(chunk);
|
|
991
|
+
if (!file) return null;
|
|
992
|
+
files.push(file);
|
|
993
|
+
}
|
|
994
|
+
return files;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const SHOW_COMMIT_LINE = /^commit ([0-9a-f]{4,40})/;
|
|
998
|
+
const SHOW_SUBJECT_LINE = /^ {4}(.+)$/;
|
|
999
|
+
|
|
1000
|
+
function parseGitDiff(text: string, show: boolean): GitDiffParsed | null {
|
|
1001
|
+
const raw = String(text ?? "").replace(/\r/g, "");
|
|
1002
|
+
let body = raw;
|
|
1003
|
+
let hash: string | undefined;
|
|
1004
|
+
let subject: string | undefined;
|
|
1005
|
+
if (show) {
|
|
1006
|
+
const diffIndex = raw.indexOf("diff --git");
|
|
1007
|
+
if (diffIndex < 0) return null; // commit with no patch / blob content → raw shell
|
|
1008
|
+
const headerPart = raw.slice(0, diffIndex);
|
|
1009
|
+
body = raw.slice(diffIndex);
|
|
1010
|
+
const commitMatch = SHOW_COMMIT_LINE.exec(headerPart);
|
|
1011
|
+
if (commitMatch) hash = commitMatch[1];
|
|
1012
|
+
for (const headerLine of headerPart.split("\n")) {
|
|
1013
|
+
const subjectMatch = SHOW_SUBJECT_LINE.exec(headerLine);
|
|
1014
|
+
if (subjectMatch) {
|
|
1015
|
+
subject = (subjectMatch[1] ?? "").trim();
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const files = parseUnifiedDiff(body);
|
|
1021
|
+
if (!files) return null;
|
|
1022
|
+
return {
|
|
1023
|
+
kind: "diff",
|
|
1024
|
+
show,
|
|
1025
|
+
files,
|
|
1026
|
+
...(hash !== undefined ? { hash } : {}),
|
|
1027
|
+
...(subject !== undefined ? { subject } : {}),
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ── git commit / push / pull / fetch parsers ─────────────────────────────────
|
|
1032
|
+
// State-change commands render a boxless summary card when their output parses
|
|
1033
|
+
// (ADR 0005). Every parser is fail-closed: a single unrecognized line returns
|
|
1034
|
+
// null and the boxed Bash shell renders raw. `git commit` with nothing staged
|
|
1035
|
+
// exits nonzero; those informational exit-1 shapes (clean tree, unstaged-only)
|
|
1036
|
+
// still parse to a `nothing to commit` card, while genuine errors (push
|
|
1037
|
+
// rejected, hook failure) hold an unrecognized line and fall back raw.
|
|
1038
|
+
|
|
1039
|
+
/** `[<branch> <hash>] <subject>` — the first line of a successful commit. */
|
|
1040
|
+
const COMMIT_SUCCESS = /^\[(\S+) ([0-9a-f]{7,40})\] (.*)$/;
|
|
1041
|
+
|
|
1042
|
+
/** Ref-update line with a status marker + bracketed/bare label, after leading
|
|
1043
|
+
* whitespace is trimmed: `* [new branch] src -> dst`, `* branch src -> dst`,
|
|
1044
|
+
* `= [up to date] src -> dst`. `!` (rejected) is excluded so rejected pushes
|
|
1045
|
+
* fall back to the raw shell instead of rendering a partial card. */
|
|
1046
|
+
const REF_CHAR_LABEL = /^([*=.-]) (?:\[([^\]]*)\]|(\S+))\s+(\S+)\s+->\s+(\S+)$/;
|
|
1047
|
+
/** Ref-update line carrying a hash range (update), after trimming:
|
|
1048
|
+
* `<a>..<b> src -> dst`. */
|
|
1049
|
+
const REF_RANGE = /^([0-9a-f]{4,}\.\.[0-9a-f]{4,})\s+(\S+)\s+->\s+(\S+)$/;
|
|
1050
|
+
|
|
1051
|
+
/** Normalize a push/fetch ref-update line by collapsing alignment whitespace
|
|
1052
|
+
* to single spaces. Returns null when the line is neither a char-labeled nor a
|
|
1053
|
+
* range ref update (caller fails closed). */
|
|
1054
|
+
function normalizeRefLine(trimmed: string): string | null {
|
|
1055
|
+
const labeled = REF_CHAR_LABEL.exec(trimmed);
|
|
1056
|
+
if (labeled) {
|
|
1057
|
+
const marker = labeled[1] ?? "";
|
|
1058
|
+
const label = labeled[2] !== undefined ? `[${labeled[2]}]` : (labeled[3] ?? "");
|
|
1059
|
+
return `${marker} ${label} ${labeled[4]} -> ${labeled[5]}`;
|
|
1060
|
+
}
|
|
1061
|
+
const range = REF_RANGE.exec(trimmed);
|
|
1062
|
+
if (range) return `${range[1]} ${range[2]} -> ${range[3]}`;
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Push/fetch progress chatter lines (stderr mixed into the captured output)
|
|
1067
|
+
* that carry no ref information — skipped without failing the parse. */
|
|
1068
|
+
function isProgressNoise(line: string): boolean {
|
|
1069
|
+
return (
|
|
1070
|
+
/^(?:Enumerating|Counting|Compressing|Writing|Deltaing|Resolving|Using) objects:/i.test(line) ||
|
|
1071
|
+
/^Total \d+/i.test(line) ||
|
|
1072
|
+
line.startsWith("remote: ") ||
|
|
1073
|
+
line.startsWith("remote:")
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function parseGitCommit(text: string): GitActionParsed | null {
|
|
1078
|
+
const lines = String(text ?? "")
|
|
1079
|
+
.replace(/\r/g, "")
|
|
1080
|
+
.split("\n")
|
|
1081
|
+
.map((line) => line.trimEnd());
|
|
1082
|
+
let start = 0;
|
|
1083
|
+
while (start < lines.length && (lines[start] ?? "") === "") start++;
|
|
1084
|
+
let end = lines.length;
|
|
1085
|
+
while (end > start && (lines[end - 1] ?? "") === "") end--;
|
|
1086
|
+
const body = lines.slice(start, end);
|
|
1087
|
+
if (body.length === 0) return null;
|
|
1088
|
+
|
|
1089
|
+
const head = COMMIT_SUCCESS.exec(body[0] ?? "");
|
|
1090
|
+
if (head) {
|
|
1091
|
+
const branch = head[1] ?? "";
|
|
1092
|
+
const hash = head[2] ?? "";
|
|
1093
|
+
const subject = (head[3] ?? "").trim();
|
|
1094
|
+
if (!branch || !hash) return null;
|
|
1095
|
+
// Without `-v` only an optional summary line follows; any other extra line
|
|
1096
|
+
// (a per-file row, editor output) fails closed.
|
|
1097
|
+
let filesChanged: number | undefined;
|
|
1098
|
+
let insertions: number | undefined;
|
|
1099
|
+
let deletions: number | undefined;
|
|
1100
|
+
for (const line of body.slice(1)) {
|
|
1101
|
+
const summary = DIFF_STAT_SUMMARY.exec(line);
|
|
1102
|
+
if (!summary) return null;
|
|
1103
|
+
filesChanged = Number(summary[1]);
|
|
1104
|
+
if (summary[2] !== undefined) insertions = Number(summary[2]);
|
|
1105
|
+
if (summary[3] !== undefined) deletions = Number(summary[3]);
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
kind: "action",
|
|
1109
|
+
command: "commit",
|
|
1110
|
+
files: [],
|
|
1111
|
+
branch,
|
|
1112
|
+
hash,
|
|
1113
|
+
...(subject ? { subject } : {}),
|
|
1114
|
+
...(filesChanged !== undefined ? { filesChanged } : {}),
|
|
1115
|
+
...(insertions !== undefined ? { insertions } : {}),
|
|
1116
|
+
...(deletions !== undefined ? { deletions } : {}),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// `git commit` with nothing to commit exits 1: either a clean tree or
|
|
1121
|
+
// unstaged/untracked-only changes. Both carry `On branch <name>` and a
|
|
1122
|
+
// terminator line; render a single `nothing to commit` row.
|
|
1123
|
+
const first = body[0] ?? "";
|
|
1124
|
+
const last = body[body.length - 1] ?? "";
|
|
1125
|
+
const cleanNothing = body.some((line) => line === "nothing to commit, working tree clean");
|
|
1126
|
+
const unstagedNothing = last.startsWith("no changes added to commit");
|
|
1127
|
+
if (/^On branch .+/.test(first) && (cleanNothing || unstagedNothing)) {
|
|
1128
|
+
return { kind: "action", command: "commit", files: [], status: "nothing to commit" };
|
|
1129
|
+
}
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function parseGitPush(text: string): GitActionParsed | null {
|
|
1134
|
+
let remote: string | undefined;
|
|
1135
|
+
let status: string | undefined;
|
|
1136
|
+
const refs: string[] = [];
|
|
1137
|
+
let sawContent = false;
|
|
1138
|
+
for (const rawLine of String(text ?? "")
|
|
1139
|
+
.replace(/\r/g, "")
|
|
1140
|
+
.split("\n")) {
|
|
1141
|
+
const line = rawLine.trimEnd();
|
|
1142
|
+
if (line === "") continue;
|
|
1143
|
+
if (isProgressNoise(line)) continue;
|
|
1144
|
+
if (/^branch '.*' set up to track '.*'\.$/.test(line)) continue; // tracking info
|
|
1145
|
+
if (line.startsWith("Pushing to ")) continue; // verbose (rejected) preamble
|
|
1146
|
+
const toLine = /^To (.+)$/.exec(line);
|
|
1147
|
+
if (toLine) {
|
|
1148
|
+
sawContent = true;
|
|
1149
|
+
remote = toLine[1] ?? "";
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (line === "Everything up-to-date") {
|
|
1153
|
+
sawContent = true;
|
|
1154
|
+
status = "Everything up-to-date";
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const ref = normalizeRefLine(line.trim());
|
|
1158
|
+
if (ref) {
|
|
1159
|
+
sawContent = true;
|
|
1160
|
+
refs.push(ref);
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
return null; // rejected (`! [...]`), error:, or unknown line → fail closed
|
|
1164
|
+
}
|
|
1165
|
+
if (!sawContent) return null;
|
|
1166
|
+
return {
|
|
1167
|
+
kind: "action",
|
|
1168
|
+
command: "push",
|
|
1169
|
+
files: [],
|
|
1170
|
+
...(remote !== undefined ? { remote } : {}),
|
|
1171
|
+
...(status !== undefined ? { status } : {}),
|
|
1172
|
+
...(refs.length > 0 ? { refs } : {}),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function parseGitPull(text: string): GitActionParsed | null {
|
|
1177
|
+
const lines = String(text ?? "")
|
|
1178
|
+
.replace(/\r/g, "")
|
|
1179
|
+
.split("\n")
|
|
1180
|
+
.map((line) => line.trimEnd());
|
|
1181
|
+
const nonEmpty = lines.filter((line) => line !== "");
|
|
1182
|
+
if (nonEmpty.length === 0) return null;
|
|
1183
|
+
if (nonEmpty.length === 1 && nonEmpty[0] === "Already up to date.") {
|
|
1184
|
+
return { kind: "action", command: "pull", files: [], status: "Already up to date." };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Fast-forward: an optional `From <url>`/ref block, then `Updating a..b`,
|
|
1188
|
+
// `Fast-forward`, and the same per-file stat block `git diff --stat` reads.
|
|
1189
|
+
let range: string | undefined;
|
|
1190
|
+
let ffIndex = -1;
|
|
1191
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
1192
|
+
const line = lines[idx] ?? "";
|
|
1193
|
+
if (line === "") continue;
|
|
1194
|
+
if (line.startsWith("From ")) continue;
|
|
1195
|
+
if (/^\s+[0-9a-f]{4,}\.\.[0-9a-f]{4,}\s+.+ -> .+$/.test(line)) continue; // fetch-style ref row
|
|
1196
|
+
const updating = /^Updating ([0-9a-f]{4,}\.\.[0-9a-f]{4,})$/.exec(line);
|
|
1197
|
+
if (updating) {
|
|
1198
|
+
range = updating[1] ?? "";
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (line === "Fast-forward") {
|
|
1202
|
+
ffIndex = idx;
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
return null; // merge output, conflict markers, localized text → fail closed
|
|
1206
|
+
}
|
|
1207
|
+
if (!range || ffIndex < 0) return null;
|
|
1208
|
+
const stat = parseGitDiffStat(lines.slice(ffIndex + 1).join("\n"));
|
|
1209
|
+
if (!stat) return null;
|
|
1210
|
+
return {
|
|
1211
|
+
kind: "action",
|
|
1212
|
+
command: "pull",
|
|
1213
|
+
files: stat.files,
|
|
1214
|
+
status: "Fast-forward",
|
|
1215
|
+
range,
|
|
1216
|
+
...(stat.filesChanged !== undefined ? { filesChanged: stat.filesChanged } : {}),
|
|
1217
|
+
...(stat.insertions !== undefined ? { insertions: stat.insertions } : {}),
|
|
1218
|
+
...(stat.deletions !== undefined ? { deletions: stat.deletions } : {}),
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function parseGitFetch(text: string): GitActionParsed | null {
|
|
1223
|
+
let remote: string | undefined;
|
|
1224
|
+
const refs: string[] = [];
|
|
1225
|
+
let sawContent = false;
|
|
1226
|
+
for (const rawLine of String(text ?? "")
|
|
1227
|
+
.replace(/\r/g, "")
|
|
1228
|
+
.split("\n")) {
|
|
1229
|
+
const line = rawLine.trimEnd();
|
|
1230
|
+
if (line === "") continue;
|
|
1231
|
+
if (isProgressNoise(line)) continue;
|
|
1232
|
+
const fromLine = /^From (.+)$/.exec(line);
|
|
1233
|
+
if (fromLine) {
|
|
1234
|
+
sawContent = true;
|
|
1235
|
+
remote = fromLine[1] ?? "";
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
const ref = normalizeRefLine(line.trim());
|
|
1239
|
+
if (ref) {
|
|
1240
|
+
sawContent = true;
|
|
1241
|
+
refs.push(ref);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
return null; // unknown line → fail closed
|
|
1245
|
+
}
|
|
1246
|
+
if (!sawContent) return { kind: "action", command: "fetch", files: [], status: "no new refs" };
|
|
1247
|
+
return {
|
|
1248
|
+
kind: "action",
|
|
1249
|
+
command: "fetch",
|
|
1250
|
+
files: [],
|
|
1251
|
+
...(remote !== undefined ? { remote } : {}),
|
|
1252
|
+
...(refs.length > 0 ? { refs } : {}),
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Lines appended to a merge/pull fast-forward stat block (after the summary)
|
|
1257
|
+
* that carry no per-file change count — file mode/creation/deletion/rename
|
|
1258
|
+
* notices. Filtered before the diff-stat line parser runs so merge stat
|
|
1259
|
+
* blocks parse; legit stat rows (`path | N`, `path | Bin`, the summary) never
|
|
1260
|
+
* match these shapes, so filtering is safe (ADR 0005). */
|
|
1261
|
+
const DIFF_STAT_NOTICE_LINE =
|
|
1262
|
+
/^\s+(?:create|delete) mode \d+ |^\s+(?:old|new) mode |^\s+mode change |^\s+(?:similarity|dissimilarity) index |^\s+(?:rename|copy) (?:from|to) |^\s+rewrite /;
|
|
1263
|
+
|
|
1264
|
+
/** Parse a diff-stat block that may carry trailing file-mode/rename notices
|
|
1265
|
+
* (merge / pull fast-forward output). Shares `parseGitDiffStat` after the
|
|
1266
|
+
* notices are stripped; fails closed on any other hostile line. */
|
|
1267
|
+
function parseGitDiffStatTolerant(text: string): GitDiffStatParsed | null {
|
|
1268
|
+
const filtered = String(text ?? "")
|
|
1269
|
+
.split("\n")
|
|
1270
|
+
.filter((line) => !DIFF_STAT_NOTICE_LINE.test(line))
|
|
1271
|
+
.join("\n");
|
|
1272
|
+
return parseGitDiffStat(filtered);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** `git switch`/`checkout`: `Switched to a new branch 'X'`, `Switched to branch
|
|
1276
|
+
* 'X'`, `Already on 'X'`, silent success (empty), or `Updated N paths from
|
|
1277
|
+
* the index` (checkout of paths). Advisory `Your branch …` lines are skipped
|
|
1278
|
+
* (the status line owns branch state). */
|
|
1279
|
+
const SWITCH_NEW_BRANCH = /^Switched to a new branch '(.+)'$/;
|
|
1280
|
+
const SWITCH_BRANCH = /^Switched to branch '(.+)'$/;
|
|
1281
|
+
const SWITCH_ALREADY = /^Already on '(.+)'$/;
|
|
1282
|
+
const CHECKOUT_PATHS = /^Updated (\d+) paths? from the index$/;
|
|
1283
|
+
|
|
1284
|
+
function parseGitSwitchCheckout(text: string, command: "switch" | "checkout"): GitActionParsed | null {
|
|
1285
|
+
const significant: string[] = [];
|
|
1286
|
+
for (const rawLine of String(text ?? "")
|
|
1287
|
+
.replace(/\r/g, "")
|
|
1288
|
+
.split("\n")) {
|
|
1289
|
+
const line = rawLine.trimEnd();
|
|
1290
|
+
if (line === "") continue;
|
|
1291
|
+
// Advisory branch-state lines and their hints — the status line owns `⎇ main`.
|
|
1292
|
+
if (line.startsWith("Your branch ")) continue;
|
|
1293
|
+
if (/^\s+\(/.test(line)) continue;
|
|
1294
|
+
significant.push(line);
|
|
1295
|
+
}
|
|
1296
|
+
if (significant.length === 0) {
|
|
1297
|
+
return { kind: "action", command, files: [], status: "completed, no output" };
|
|
1298
|
+
}
|
|
1299
|
+
if (significant.length === 1) {
|
|
1300
|
+
const line = significant[0] ?? "";
|
|
1301
|
+
const created = SWITCH_NEW_BRANCH.exec(line);
|
|
1302
|
+
if (created && created[1] !== undefined)
|
|
1303
|
+
return { kind: "action", command, files: [], branch: created[1], created: true };
|
|
1304
|
+
const existing = SWITCH_BRANCH.exec(line);
|
|
1305
|
+
if (existing && existing[1] !== undefined) return { kind: "action", command, files: [], branch: existing[1] };
|
|
1306
|
+
const already = SWITCH_ALREADY.exec(line);
|
|
1307
|
+
if (already && already[1] !== undefined) return { kind: "action", command, files: [], branch: already[1] };
|
|
1308
|
+
const paths = CHECKOUT_PATHS.exec(line);
|
|
1309
|
+
if (paths) {
|
|
1310
|
+
const count = Number(paths[1]);
|
|
1311
|
+
return {
|
|
1312
|
+
kind: "action",
|
|
1313
|
+
command,
|
|
1314
|
+
files: [],
|
|
1315
|
+
status: `Updated ${count} ${pluralForm("file", count)} from the index`,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return null; // detached-HEAD note, `switch -m` merge rows, localized text → fail closed
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** `git add`/`restore`: success is silent (empty output). Any non-empty output
|
|
1323
|
+
* is an error/`-v` listing/localized text → fail closed. */
|
|
1324
|
+
function parseGitAddRestore(text: string, command: "add" | "restore"): GitActionParsed | null {
|
|
1325
|
+
const body = String(text ?? "").replace(/\r/g, "");
|
|
1326
|
+
if (body.trim() === "") {
|
|
1327
|
+
return { kind: "action", command, files: [], status: "completed, no output" };
|
|
1328
|
+
}
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** `git reset`: `HEAD is now at <hash> <subject>` (`--hard`/`--keep`), the
|
|
1333
|
+
* `Unstaged changes after reset:` block with `<marker>\t<path>` rows
|
|
1334
|
+
* (`--mixed`), or silent success (`--soft`, or a clean mixed reset). */
|
|
1335
|
+
const RESET_HEAD_NOW = /^HEAD is now at ([0-9a-f]{4,40}) (.*)$/;
|
|
1336
|
+
const RESET_UNSTAGED_HEADER = "Unstaged changes after reset:";
|
|
1337
|
+
const RESET_UNSTAGED_ROW = /^([MADRC?!]{1,2})\t(.+)$/;
|
|
1338
|
+
|
|
1339
|
+
function parseGitReset(text: string): GitActionParsed | null {
|
|
1340
|
+
const lines = String(text ?? "")
|
|
1341
|
+
.replace(/\r/g, "")
|
|
1342
|
+
.split("\n")
|
|
1343
|
+
.map((line) => line.trimEnd());
|
|
1344
|
+
const nonEmpty = lines.filter((line) => line !== "");
|
|
1345
|
+
if (nonEmpty.length === 0) {
|
|
1346
|
+
return { kind: "action", command: "reset", files: [], status: "completed, no output" };
|
|
1347
|
+
}
|
|
1348
|
+
if (nonEmpty.length === 1) {
|
|
1349
|
+
const head = RESET_HEAD_NOW.exec(nonEmpty[0] ?? "");
|
|
1350
|
+
if (head && head[1] !== undefined) {
|
|
1351
|
+
const subject = (head[2] ?? "").trim();
|
|
1352
|
+
return {
|
|
1353
|
+
kind: "action",
|
|
1354
|
+
command: "reset",
|
|
1355
|
+
files: [],
|
|
1356
|
+
hash: head[1],
|
|
1357
|
+
...(subject ? { subject } : {}),
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
if ((nonEmpty[0] ?? "") === RESET_UNSTAGED_HEADER) {
|
|
1363
|
+
const resetFiles: GitStatusFile[] = [];
|
|
1364
|
+
for (const row of nonEmpty.slice(1)) {
|
|
1365
|
+
const match = RESET_UNSTAGED_ROW.exec(row ?? "");
|
|
1366
|
+
if (!match) return null;
|
|
1367
|
+
const marker = match[1] ?? "";
|
|
1368
|
+
resetFiles.push({ x: marker[0] ?? " ", y: marker[1] ?? " ", path: match[2] ?? "" });
|
|
1369
|
+
}
|
|
1370
|
+
if (resetFiles.length === 0) return null;
|
|
1371
|
+
return { kind: "action", command: "reset", files: [], resetFiles };
|
|
1372
|
+
}
|
|
1373
|
+
return null; // unrecognized multi-line shape → fail closed
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** `git merge`: `Already up to date.`, a fast-forward (`Updating a..b` +
|
|
1377
|
+
* `Fast-forward` + stat block), or `Merge made by the '…' strategy.` + stat
|
|
1378
|
+
* block. Conflicts exit nonzero and hold unrecognized lines → fail closed. */
|
|
1379
|
+
const MERGE_UPDATING = /^Updating ([0-9a-f]{4,}\.\.[0-9a-f]{4,})$/;
|
|
1380
|
+
const MERGE_MADE = /^Merge made by the '.*' strategy\.$/;
|
|
1381
|
+
|
|
1382
|
+
function parseGitMerge(text: string): GitActionParsed | null {
|
|
1383
|
+
const lines = String(text ?? "")
|
|
1384
|
+
.replace(/\r/g, "")
|
|
1385
|
+
.split("\n")
|
|
1386
|
+
.map((line) => line.trimEnd());
|
|
1387
|
+
let start = 0;
|
|
1388
|
+
while (start < lines.length && (lines[start] ?? "") === "") start++;
|
|
1389
|
+
let end = lines.length;
|
|
1390
|
+
while (end > start && (lines[end - 1] ?? "") === "") end--;
|
|
1391
|
+
const body = lines.slice(start, end);
|
|
1392
|
+
if (body.length === 0) return null; // merge always reports; empty → hostile
|
|
1393
|
+
|
|
1394
|
+
if (body.length === 1 && (body[0] ?? "") === "Already up to date.") {
|
|
1395
|
+
return { kind: "action", command: "merge", files: [], status: "Already up to date." };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
let idx = 0;
|
|
1399
|
+
let range: string | undefined;
|
|
1400
|
+
const updating = MERGE_UPDATING.exec(body[0] ?? "");
|
|
1401
|
+
if (updating) {
|
|
1402
|
+
range = updating[1];
|
|
1403
|
+
idx = 1;
|
|
1404
|
+
}
|
|
1405
|
+
const marker = body[idx] ?? "";
|
|
1406
|
+
if (range && marker === "Fast-forward") {
|
|
1407
|
+
const stat = parseGitDiffStatTolerant(body.slice(idx + 1).join("\n"));
|
|
1408
|
+
if (!stat) return null;
|
|
1409
|
+
return {
|
|
1410
|
+
kind: "action",
|
|
1411
|
+
command: "merge",
|
|
1412
|
+
files: stat.files,
|
|
1413
|
+
status: "Fast-forward",
|
|
1414
|
+
range,
|
|
1415
|
+
...(stat.filesChanged !== undefined ? { filesChanged: stat.filesChanged } : {}),
|
|
1416
|
+
...(stat.insertions !== undefined ? { insertions: stat.insertions } : {}),
|
|
1417
|
+
...(stat.deletions !== undefined ? { deletions: stat.deletions } : {}),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
if (MERGE_MADE.exec(marker)) {
|
|
1421
|
+
const stat = parseGitDiffStatTolerant(body.slice(idx + 1).join("\n"));
|
|
1422
|
+
if (!stat) return null;
|
|
1423
|
+
return {
|
|
1424
|
+
kind: "action",
|
|
1425
|
+
command: "merge",
|
|
1426
|
+
files: stat.files,
|
|
1427
|
+
status: marker,
|
|
1428
|
+
...(stat.filesChanged !== undefined ? { filesChanged: stat.filesChanged } : {}),
|
|
1429
|
+
...(stat.insertions !== undefined ? { insertions: stat.insertions } : {}),
|
|
1430
|
+
...(stat.deletions !== undefined ? { deletions: stat.deletions } : {}),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
return null; // conflict markers, `--squash`/`--abort` output → fail closed
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** `git rebase`: `Successfully rebased and updated refs/heads/<branch>.` or
|
|
1437
|
+
* `Current branch <branch> is up to date.`. The `Rebasing (N/M)` progress is
|
|
1438
|
+
* carriage-return-separated; `\r` is split into its own line and dropped.
|
|
1439
|
+
* Conflicts/`--abort`/`--continue` exit nonzero and fail closed. */
|
|
1440
|
+
const REBASE_SUCCESS = /^Successfully rebased and updated refs\/heads\/(.+)$/;
|
|
1441
|
+
const REBASE_UPTODATE = /^Current branch (.+) is up to date\.$/;
|
|
1442
|
+
|
|
1443
|
+
function parseGitRebase(text: string): GitActionParsed | null {
|
|
1444
|
+
const significant: string[] = [];
|
|
1445
|
+
for (const rawLine of String(text ?? "")
|
|
1446
|
+
.replace(/\r/g, "\n")
|
|
1447
|
+
.split("\n")) {
|
|
1448
|
+
const line = rawLine.trimEnd();
|
|
1449
|
+
if (line === "") continue;
|
|
1450
|
+
// Progress chatter written with a carriage return, then overwritten.
|
|
1451
|
+
if (/^Rebasing \(\d+\/\d+\)/.test(line)) continue;
|
|
1452
|
+
if (/^Rewriting commits \(\d+\/\d+\)/.test(line)) continue;
|
|
1453
|
+
significant.push(line);
|
|
1454
|
+
}
|
|
1455
|
+
if (significant.length === 1) {
|
|
1456
|
+
const line = significant[0] ?? "";
|
|
1457
|
+
const success = REBASE_SUCCESS.exec(line);
|
|
1458
|
+
if (success && success[1] !== undefined)
|
|
1459
|
+
return {
|
|
1460
|
+
kind: "action",
|
|
1461
|
+
command: "rebase",
|
|
1462
|
+
files: [],
|
|
1463
|
+
branch: success[1].replace(/\.$/, ""),
|
|
1464
|
+
status: "Rebased",
|
|
1465
|
+
};
|
|
1466
|
+
const upToDate = REBASE_UPTODATE.exec(line);
|
|
1467
|
+
if (upToDate && upToDate[1] !== undefined)
|
|
1468
|
+
return { kind: "action", command: "rebase", files: [], branch: upToDate[1], status: "Up to date." };
|
|
1469
|
+
}
|
|
1470
|
+
return null; // conflict, `--abort`/`--continue`, interactive editor → fail closed
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function parseGitAction(command: GitActionParsed["command"], text: string): GitActionParsed | null {
|
|
1474
|
+
if (command === "commit") return parseGitCommit(text);
|
|
1475
|
+
if (command === "push") return parseGitPush(text);
|
|
1476
|
+
if (command === "pull") return parseGitPull(text);
|
|
1477
|
+
if (command === "fetch") return parseGitFetch(text);
|
|
1478
|
+
if (command === "switch" || command === "checkout") return parseGitSwitchCheckout(text, command);
|
|
1479
|
+
if (command === "add" || command === "restore") return parseGitAddRestore(text, command);
|
|
1480
|
+
if (command === "reset") return parseGitReset(text);
|
|
1481
|
+
if (command === "merge") return parseGitMerge(text);
|
|
1482
|
+
return parseGitRebase(text);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
1486
|
+
|
|
1487
|
+
/** Nerd Font git-branch glyph used on git card headers in Nerd Font mode. */
|
|
1488
|
+
export const GIT_ICON = "\u{E725}";
|
|
1489
|
+
|
|
1490
|
+
const GIT_CARD_HEAD_LIMIT = 6;
|
|
1491
|
+
const GIT_CONFLICT_PAIRS = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]);
|
|
1492
|
+
|
|
1493
|
+
function gitCardHeader(theme: BoxTheme, cls: GitSemanticClass, parsed?: GitParsedSemantic): string {
|
|
1494
|
+
const icon = getToolsRenderConfig().nerdFonts ? `${GIT_ICON} ` : "";
|
|
1495
|
+
let prefix: string;
|
|
1496
|
+
if (cls.kind === "diff") {
|
|
1497
|
+
const label = cls.show ? "Git show" : "Git diff";
|
|
1498
|
+
prefix = `${icon}${label}`;
|
|
1499
|
+
if (cls.show && parsed?.kind === "diff" && parsed.hash) {
|
|
1500
|
+
const shortHash = parsed.hash.slice(0, 7);
|
|
1501
|
+
prefix += ` · ${shortHash}`;
|
|
1502
|
+
if (parsed.subject) prefix += ` · ${parsed.subject}`;
|
|
1503
|
+
}
|
|
1504
|
+
} else if (cls.kind === "show-stat") {
|
|
1505
|
+
prefix = `${icon}Git show`;
|
|
1506
|
+
if (parsed?.kind === "show-stat") {
|
|
1507
|
+
prefix += ` · ${parsed.hash.slice(0, 7)}`;
|
|
1508
|
+
if (parsed.subject) prefix += ` · ${parsed.subject}`;
|
|
1509
|
+
}
|
|
1510
|
+
} else if (cls.kind === "action") {
|
|
1511
|
+
const label =
|
|
1512
|
+
cls.command === "commit"
|
|
1513
|
+
? "Git commit"
|
|
1514
|
+
: cls.command === "push"
|
|
1515
|
+
? "Git push"
|
|
1516
|
+
: cls.command === "pull"
|
|
1517
|
+
? "Git pull"
|
|
1518
|
+
: cls.command === "fetch"
|
|
1519
|
+
? "Git fetch"
|
|
1520
|
+
: cls.command === "switch"
|
|
1521
|
+
? "Git switch"
|
|
1522
|
+
: cls.command === "checkout"
|
|
1523
|
+
? "Git checkout"
|
|
1524
|
+
: cls.command === "add"
|
|
1525
|
+
? "Git add"
|
|
1526
|
+
: cls.command === "restore"
|
|
1527
|
+
? "Git restore"
|
|
1528
|
+
: cls.command === "reset"
|
|
1529
|
+
? "Git reset"
|
|
1530
|
+
: cls.command === "merge"
|
|
1531
|
+
? "Git merge"
|
|
1532
|
+
: "Git rebase";
|
|
1533
|
+
prefix = `${icon}${label}`;
|
|
1534
|
+
// Header detail carries the parsed identity, matching `Git show · hash ·
|
|
1535
|
+
// subject`: a commit's `[<branch> <hash>] <subject>`, a switch/checkout
|
|
1536
|
+
// target branch, a reset --hard `HEAD is now at <hash> <subject>`, or a
|
|
1537
|
+
// rebase `<branch>`. add/restore/merge stay label-only.
|
|
1538
|
+
if (parsed?.kind === "action") {
|
|
1539
|
+
if ((parsed.command === "commit" || parsed.command === "reset") && parsed.hash) {
|
|
1540
|
+
prefix += ` · ${parsed.hash.slice(0, 7)}`;
|
|
1541
|
+
if (parsed.subject) prefix += ` · ${parsed.subject}`;
|
|
1542
|
+
} else if (
|
|
1543
|
+
(parsed.command === "switch" || parsed.command === "checkout" || parsed.command === "rebase") &&
|
|
1544
|
+
parsed.branch
|
|
1545
|
+
) {
|
|
1546
|
+
prefix += ` · ${parsed.branch}`;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
} else {
|
|
1550
|
+
const label = cls.kind === "status" ? "Git status" : cls.kind === "diff-stat" ? "Git diff --stat" : "Git log";
|
|
1551
|
+
prefix = `${icon}${label}`;
|
|
1552
|
+
}
|
|
1553
|
+
return typeof theme?.bold === "function" ? theme.bold(prefix) : prefix;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function statusMarker(file: GitStatusFile): string {
|
|
1557
|
+
const xy = `${file.x}${file.y}`;
|
|
1558
|
+
if (GIT_CONFLICT_PAIRS.has(xy)) return "U";
|
|
1559
|
+
if (file.x === "?" || file.x === "!") return file.x;
|
|
1560
|
+
const staged = file.x !== " " ? file.x : "";
|
|
1561
|
+
const worktree = file.y !== " " && file.y !== "?" && file.y !== "!" ? file.y : "";
|
|
1562
|
+
return `${staged}${worktree}` || " ";
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function statusMarkColor(file: GitStatusFile): string {
|
|
1566
|
+
const xy = `${file.x}${file.y}`;
|
|
1567
|
+
if (GIT_CONFLICT_PAIRS.has(xy)) return "error";
|
|
1568
|
+
if (file.x === "?") return "warning";
|
|
1569
|
+
if (file.x === "!") return "dim";
|
|
1570
|
+
if (file.x !== " ") return "accent";
|
|
1571
|
+
return "toolOutput";
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function statusCounts(theme: BoxTheme, parsed: GitStatusParsed): string[] {
|
|
1575
|
+
let staged = 0;
|
|
1576
|
+
let modified = 0;
|
|
1577
|
+
let untracked = 0;
|
|
1578
|
+
let ignored = 0;
|
|
1579
|
+
let conflicted = 0;
|
|
1580
|
+
for (const file of parsed.files) {
|
|
1581
|
+
const xy = `${file.x}${file.y}`;
|
|
1582
|
+
if (GIT_CONFLICT_PAIRS.has(xy)) conflicted++;
|
|
1583
|
+
else if (file.x === "?" || file.x === "!") {
|
|
1584
|
+
if (file.x === "!") ignored++;
|
|
1585
|
+
else untracked++;
|
|
1586
|
+
} else if (file.x !== " ") staged++;
|
|
1587
|
+
else if (file.y !== " ") modified++;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const parts: string[] = [];
|
|
1591
|
+
if (conflicted > 0) parts.push(theme.fg("error", `${conflicted} conflicted`));
|
|
1592
|
+
if (staged > 0) parts.push(theme.fg("accent", `${staged} staged`));
|
|
1593
|
+
if (modified > 0) parts.push(theme.fg("accent", `${modified} modified`));
|
|
1594
|
+
if (untracked > 0) parts.push(theme.fg("warning", `${untracked} untracked`));
|
|
1595
|
+
if (ignored > 0) parts.push(theme.fg("dim", `${ignored} ignored`));
|
|
1596
|
+
return parts;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function renderStatusCard(theme: BoxTheme, parsed: GitStatusParsed, out: string[], width: number): string[] {
|
|
1600
|
+
const counts = statusCounts(theme, parsed);
|
|
1601
|
+
if (counts.length > 0) out.push(` ${counts.join(theme.fg("dim", " · "))}`);
|
|
1602
|
+
else out.push(theme.fg("muted", " nothing to commit, working tree clean"));
|
|
1603
|
+
|
|
1604
|
+
// Branch is only shown when it affects the result (push/merge/ahead-behind);
|
|
1605
|
+
// the status line owns `⎇ main` (ADR 0005).
|
|
1606
|
+
if (parsed.branch && (parsed.ahead !== undefined || parsed.behind !== undefined)) {
|
|
1607
|
+
const parts = [theme.fg("text", parsed.branch)];
|
|
1608
|
+
if (parsed.ahead !== undefined) parts.push(theme.fg("accent", `ahead ${parsed.ahead}`));
|
|
1609
|
+
if (parsed.behind !== undefined) parts.push(theme.fg("warning", `behind ${parsed.behind}`));
|
|
1610
|
+
out.push(` ${parts.join(theme.fg("dim", " · "))}`);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const files = parsed.files;
|
|
1614
|
+
return renderStatusFileRows(theme, files, out, width);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** Shared `├─ M path` rows for status-style file lists (status card + reset). */
|
|
1618
|
+
function renderStatusFileRows(
|
|
1619
|
+
theme: BoxTheme,
|
|
1620
|
+
files: readonly GitStatusFile[],
|
|
1621
|
+
out: string[],
|
|
1622
|
+
width: number,
|
|
1623
|
+
): string[] {
|
|
1624
|
+
const visible = files.slice(0, GIT_CARD_HEAD_LIMIT);
|
|
1625
|
+
const more = files.length - visible.length;
|
|
1626
|
+
const lastIndex = visible.length - 1;
|
|
1627
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1628
|
+
const file = visible[i];
|
|
1629
|
+
if (!file) continue;
|
|
1630
|
+
const branch = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
1631
|
+
const mark = statusMarker(file);
|
|
1632
|
+
const line = `${TREE_INDENT}${dimLine(branch)} ${theme.fg(statusMarkColor(file), mark)} ${theme.fg("toolOutput", file.path)}`;
|
|
1633
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
1634
|
+
}
|
|
1635
|
+
if (more > 0) {
|
|
1636
|
+
out.push(
|
|
1637
|
+
safeTruncateToWidth(
|
|
1638
|
+
`${TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`,
|
|
1639
|
+
width,
|
|
1640
|
+
"…",
|
|
1641
|
+
),
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
return out;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function renderDiffStatCard(theme: BoxTheme, parsed: DiffStatSummary, out: string[], width: number): string[] {
|
|
1648
|
+
const summaryParts: string[] = [];
|
|
1649
|
+
if (parsed.filesChanged !== undefined) {
|
|
1650
|
+
summaryParts.push(theme.fg("accent", `${parsed.filesChanged} ${pluralForm("file", parsed.filesChanged)} changed`));
|
|
1651
|
+
}
|
|
1652
|
+
// +/− totals sit adjacent (no separator between them), unlike the `·`-joined parts.
|
|
1653
|
+
const diffParts: string[] = [];
|
|
1654
|
+
if (parsed.insertions !== undefined && parsed.insertions > 0) {
|
|
1655
|
+
diffParts.push(theme.fg("toolDiffAdded", `+${parsed.insertions}`));
|
|
1656
|
+
}
|
|
1657
|
+
if (parsed.deletions !== undefined && parsed.deletions > 0) {
|
|
1658
|
+
diffParts.push(theme.fg("toolDiffRemoved", `-${parsed.deletions}`));
|
|
1659
|
+
}
|
|
1660
|
+
if (diffParts.length > 0) summaryParts.push(diffParts.join(" "));
|
|
1661
|
+
if (summaryParts.length > 0) out.push(` ${summaryParts.join(theme.fg("dim", " · "))}`);
|
|
1662
|
+
else out.push(theme.fg("muted", " no changes"));
|
|
1663
|
+
|
|
1664
|
+
const files = parsed.files;
|
|
1665
|
+
const visible = files.slice(0, GIT_CARD_HEAD_LIMIT);
|
|
1666
|
+
const more = files.length - visible.length;
|
|
1667
|
+
const lastIndex = visible.length - 1;
|
|
1668
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1669
|
+
const file = visible[i];
|
|
1670
|
+
if (!file) continue;
|
|
1671
|
+
const branch = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
1672
|
+
const changes = file.changes ?? 0;
|
|
1673
|
+
const detail = theme.fg("dim", file.binary ? "· binary" : `· ${changes} ${pluralForm("change", changes)}`);
|
|
1674
|
+
const line = `${TREE_INDENT}${dimLine(branch)} ${theme.fg("toolOutput", file.path)} ${detail}`;
|
|
1675
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
1676
|
+
}
|
|
1677
|
+
if (more > 0) {
|
|
1678
|
+
out.push(
|
|
1679
|
+
safeTruncateToWidth(
|
|
1680
|
+
`${TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`,
|
|
1681
|
+
width,
|
|
1682
|
+
"…",
|
|
1683
|
+
),
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
return out;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function renderLogCard(theme: BoxTheme, parsed: GitLogParsed, out: string[], width: number): string[] {
|
|
1690
|
+
const commits = parsed.commits;
|
|
1691
|
+
if (commits.length === 0) {
|
|
1692
|
+
out.push(theme.fg("muted", " no commits"));
|
|
1693
|
+
return out;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const visible = commits.slice(0, GIT_CARD_HEAD_LIMIT);
|
|
1697
|
+
const more = commits.length - visible.length;
|
|
1698
|
+
const lastIndex = visible.length - 1;
|
|
1699
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1700
|
+
const commit = visible[i];
|
|
1701
|
+
if (!commit) continue;
|
|
1702
|
+
const branch = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
1703
|
+
const refs = commit.refs ? ` (${commit.refs})` : "";
|
|
1704
|
+
const subject = commit.subject ? ` ${commit.subject}` : "";
|
|
1705
|
+
const line = `${TREE_INDENT}${dimLine(branch)} ${theme.fg("accent", commit.hash)}${theme.fg("dim", refs)}${theme.fg("toolOutput", subject)}`;
|
|
1706
|
+
out.push(line);
|
|
1707
|
+
}
|
|
1708
|
+
if (more > 0) {
|
|
1709
|
+
out.push(
|
|
1710
|
+
safeTruncateToWidth(
|
|
1711
|
+
`${TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("commit", more)}`)}`,
|
|
1712
|
+
width,
|
|
1713
|
+
"…",
|
|
1714
|
+
),
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
return out;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function renderDiffCard(theme: BoxTheme, parsed: GitDiffParsed, out: string[], width: number): string[] {
|
|
1721
|
+
const files = parsed.files;
|
|
1722
|
+
if (files.length === 0) {
|
|
1723
|
+
out.push(theme.fg("muted", " no changes"));
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
let additions = 0;
|
|
1727
|
+
let removals = 0;
|
|
1728
|
+
for (const file of files) {
|
|
1729
|
+
additions += file.additions;
|
|
1730
|
+
removals += file.removals;
|
|
1731
|
+
}
|
|
1732
|
+
const parts: string[] = [theme.fg("accent", `${files.length} ${pluralForm("file", files.length)}`)];
|
|
1733
|
+
if (additions > 0) parts.push(theme.fg("toolDiffAdded", `+${additions}`));
|
|
1734
|
+
if (removals > 0) parts.push(theme.fg("toolDiffRemoved", `-${removals}`));
|
|
1735
|
+
out.push(safeTruncateToWidth(` ${parts.join(" ")}`, width, "…"));
|
|
1736
|
+
return out;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Render a state-change card. `commit`/`pull` reuse the diff-stat summary +
|
|
1740
|
+
* `├─/└─` rows; `push`/`fetch` show the remote (dim) and normalized ref rows.
|
|
1741
|
+
* Every line is width-safe (the caller truncates again). */
|
|
1742
|
+
function renderActionCard(theme: BoxTheme, parsed: GitActionParsed, out: string[], width: number): string[] {
|
|
1743
|
+
if (parsed.command === "commit") {
|
|
1744
|
+
if (parsed.status) {
|
|
1745
|
+
out.push(theme.fg("muted", ` ${parsed.status}`)); // nothing to commit
|
|
1746
|
+
return out;
|
|
1747
|
+
}
|
|
1748
|
+
renderDiffStatCard(theme, parsed, out, width); // success: summary line only (no -v rows)
|
|
1749
|
+
return out;
|
|
1750
|
+
}
|
|
1751
|
+
if (parsed.command === "pull") {
|
|
1752
|
+
if (parsed.status === "Already up to date.") {
|
|
1753
|
+
out.push(theme.fg("muted", " Already up to date."));
|
|
1754
|
+
return out;
|
|
1755
|
+
}
|
|
1756
|
+
// Fast-forward: range + Fast-forward + diff-stat summary/rows.
|
|
1757
|
+
if (parsed.range) out.push(` ${theme.fg("text", parsed.range)}`);
|
|
1758
|
+
out.push(` ${theme.fg("accent", "Fast-forward")}`);
|
|
1759
|
+
renderDiffStatCard(theme, parsed, out, width);
|
|
1760
|
+
return out;
|
|
1761
|
+
}
|
|
1762
|
+
if (parsed.command === "push") {
|
|
1763
|
+
if (parsed.remote) out.push(theme.fg("dim", ` To ${parsed.remote}`));
|
|
1764
|
+
for (const ref of parsed.refs ?? []) out.push(safeTruncateToWidth(` ${theme.fg("toolOutput", ref)}`, width, "…"));
|
|
1765
|
+
if (parsed.status) out.push(theme.fg("muted", ` ${parsed.status}`)); // Everything up-to-date
|
|
1766
|
+
return out;
|
|
1767
|
+
}
|
|
1768
|
+
if (parsed.command === "fetch") {
|
|
1769
|
+
if (parsed.status) {
|
|
1770
|
+
out.push(theme.fg("muted", ` ${parsed.status}`)); // no new refs (empty fetch)
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
if (parsed.remote) out.push(theme.fg("dim", ` From ${parsed.remote}`));
|
|
1774
|
+
for (const ref of parsed.refs ?? []) out.push(safeTruncateToWidth(` ${theme.fg("toolOutput", ref)}`, width, "…"));
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
if (parsed.command === "merge") {
|
|
1778
|
+
if (parsed.status === "Already up to date.") {
|
|
1779
|
+
out.push(theme.fg("muted", " Already up to date."));
|
|
1780
|
+
return out;
|
|
1781
|
+
}
|
|
1782
|
+
// Fast-forward / `Merge made by the '…' strategy.` + stat summary/rows.
|
|
1783
|
+
if (parsed.range) out.push(` ${theme.fg("text", parsed.range)}`);
|
|
1784
|
+
if (parsed.status) out.push(` ${theme.fg("accent", parsed.status)}`);
|
|
1785
|
+
renderDiffStatCard(theme, parsed, out, width);
|
|
1786
|
+
return out;
|
|
1787
|
+
}
|
|
1788
|
+
if (parsed.command === "reset") {
|
|
1789
|
+
// Mixed reset with unstaged changes: `M path` rows (hash/subject live in
|
|
1790
|
+
// the header for --hard/--soft via `HEAD is now at`).
|
|
1791
|
+
if (parsed.resetFiles && parsed.resetFiles.length > 0) {
|
|
1792
|
+
return renderStatusFileRows(theme, parsed.resetFiles, out, width);
|
|
1793
|
+
}
|
|
1794
|
+
if (parsed.status) out.push(theme.fg("muted", ` ${parsed.status}`)); // completed, no output
|
|
1795
|
+
return out;
|
|
1796
|
+
}
|
|
1797
|
+
// switch/checkout (branch in the header), add/restore/rebase (status line).
|
|
1798
|
+
if (parsed.status) out.push(theme.fg("muted", ` ${parsed.status}`));
|
|
1799
|
+
return out;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// ── Dispatch helpers (used by bash.ts) ──────────────────────────────────────
|
|
1803
|
+
|
|
1804
|
+
export function parseGitOutput(cls: GitSemanticClass, output: string): GitParsedSemantic | null {
|
|
1805
|
+
const text = String(output ?? "");
|
|
1806
|
+
if (cls.kind === "status") return parseGitStatus(cls, text);
|
|
1807
|
+
if (cls.kind === "diff-stat") return parseGitDiffStat(text);
|
|
1808
|
+
if (cls.kind === "show-stat") return parseGitShowStat(text);
|
|
1809
|
+
if (cls.kind === "diff") return parseGitDiff(text, cls.show);
|
|
1810
|
+
if (cls.kind === "action") return parseGitAction(cls.command, text);
|
|
1811
|
+
return parseGitLog(text);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Render the git semantic card for one call: the header always renders (so a
|
|
1816
|
+
* pending call shows a single summary line); once the result parses, counts,
|
|
1817
|
+
* file/commit rows, and the `… N more` collapse follow. Every line is
|
|
1818
|
+
* width-safe.
|
|
1819
|
+
*/
|
|
1820
|
+
export function renderGitCardLines(
|
|
1821
|
+
theme: BoxTheme,
|
|
1822
|
+
state: { readonly cls: GitSemanticClass; readonly parsed?: GitParsedSemantic },
|
|
1823
|
+
width: number,
|
|
1824
|
+
): string[] {
|
|
1825
|
+
const safeWidth = Math.max(1, width);
|
|
1826
|
+
const out: string[] = [safeTruncateToWidth(gitCardHeader(theme, state.cls, state.parsed), safeWidth, "…")];
|
|
1827
|
+
const parsed = state.parsed;
|
|
1828
|
+
if (!parsed) return out;
|
|
1829
|
+
if (parsed.kind === "status") renderStatusCard(theme, parsed, out, safeWidth);
|
|
1830
|
+
else if (parsed.kind === "diff-stat") renderDiffStatCard(theme, parsed, out, safeWidth);
|
|
1831
|
+
else if (parsed.kind === "show-stat") renderDiffStatCard(theme, parsed, out, safeWidth);
|
|
1832
|
+
else if (parsed.kind === "diff") renderDiffCard(theme, parsed, out, safeWidth);
|
|
1833
|
+
else if (parsed.kind === "action") renderActionCard(theme, parsed, out, safeWidth);
|
|
1834
|
+
else renderLogCard(theme, parsed, out, safeWidth);
|
|
1835
|
+
return out.map((line) => safeTruncateToWidth(line, safeWidth, "…"));
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ── Boxed diff result (Phase 8B) ──────────────────────────────────────────
|
|
1839
|
+
// `git diff` / `git show` results render one frame per file via
|
|
1840
|
+
// `renderBoxedToolResult` + the same `AdaptiveDiffComponent` `Edit` uses — no
|
|
1841
|
+
// second diff visual language (ADR 0005 / GIT-002). The Git header lives
|
|
1842
|
+
// outside the box (the call panel card); each file gets its own `╭…╰` frame
|
|
1843
|
+
// with a `Diff · +N -M` divider and a `Ctrl+O more` expand hint when collapsed.
|
|
1844
|
+
|
|
1845
|
+
const GIT_DIFF_MAX_HIGHLIGHT_CHARS = 12000;
|
|
1846
|
+
const GIT_DIFF_MAX_HIGHLIGHT_ROWS = 120;
|
|
1847
|
+
const GIT_DIFF_MAX_ROWS_COLLAPSED = 36;
|
|
1848
|
+
const GIT_DIFF_MAX_ROWS_EXPANDED = 160;
|
|
1849
|
+
|
|
1850
|
+
function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
|
|
1851
|
+
const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
|
|
1852
|
+
const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
|
|
1853
|
+
return `Diff · ${plus} ${minus}`;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function fileBoxTopLabel(theme: BoxTheme, path: string): string {
|
|
1857
|
+
const body = theme.fg("text", path);
|
|
1858
|
+
return typeof theme?.bold === "function" ? theme.bold(body) : body;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function binaryBodyLine(theme: BoxTheme, status: GitDiffFile["status"]): string {
|
|
1862
|
+
const verb =
|
|
1863
|
+
status === "added" ? "added" : status === "deleted" ? "removed" : status === "renamed" ? "renamed" : "changed";
|
|
1864
|
+
return theme.fg("muted", `Binary file ${verb} (content not shown)`);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
interface DiffFileBox {
|
|
1868
|
+
readonly topLabel: string;
|
|
1869
|
+
readonly resultComponent: Component;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** Build a complete boxed-diff result component for a parsed `git diff`/`show`.
|
|
1873
|
+
* The call panel renders the boxless Git header; this component renders one
|
|
1874
|
+
* `╭…╰` frame per file (or a single `No changes` frame for an empty diff). */
|
|
1875
|
+
export function renderGitDiffResult(
|
|
1876
|
+
theme: BoxTheme,
|
|
1877
|
+
parsed: GitDiffParsed,
|
|
1878
|
+
options: { expanded: boolean },
|
|
1879
|
+
context: BoxedToolContext,
|
|
1880
|
+
): Component {
|
|
1881
|
+
const expanded = Boolean(options.expanded);
|
|
1882
|
+
const elapsedMs = getStateElapsedMs(context.state);
|
|
1883
|
+
const fileCount = parsed.files.length;
|
|
1884
|
+
|
|
1885
|
+
const footerParts: string[] = [];
|
|
1886
|
+
if (elapsedMs !== undefined) footerParts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
1887
|
+
footerParts.push(theme.fg("dim", `${fileCount} ${pluralForm("file", fileCount)}`));
|
|
1888
|
+
const footer = footerParts.join(theme.fg("dim", " · "));
|
|
1889
|
+
|
|
1890
|
+
const fileBoxes: DiffFileBox[] = [];
|
|
1891
|
+
if (parsed.files.length === 0) {
|
|
1892
|
+
// Empty diff (`git diff` with no changes): a single `No changes` frame so
|
|
1893
|
+
// the result is not a blank panel.
|
|
1894
|
+
const emptyFooterParts: string[] = [];
|
|
1895
|
+
if (elapsedMs !== undefined) emptyFooterParts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
1896
|
+
const emptyFooter = emptyFooterParts.join(theme.fg("dim", " · "));
|
|
1897
|
+
fileBoxes.push({
|
|
1898
|
+
topLabel: fileBoxTopLabel(theme, parsed.show ? "Git show" : "Git diff"),
|
|
1899
|
+
resultComponent: renderBoxedToolResult(theme, () => [theme.fg("muted", "No changes")], {
|
|
1900
|
+
showDivider: false,
|
|
1901
|
+
footerLines: emptyFooter ? [emptyFooter] : [],
|
|
1902
|
+
}),
|
|
1903
|
+
});
|
|
1904
|
+
} else {
|
|
1905
|
+
for (const file of parsed.files) {
|
|
1906
|
+
const topLabel = fileBoxTopLabel(theme, file.path);
|
|
1907
|
+
if (file.binary) {
|
|
1908
|
+
fileBoxes.push({
|
|
1909
|
+
topLabel,
|
|
1910
|
+
resultComponent: renderBoxedToolResult(theme, () => [binaryBodyLine(theme, file.status)], {
|
|
1911
|
+
dividerLabel: "Binary",
|
|
1912
|
+
footerLines: [footer],
|
|
1913
|
+
}),
|
|
1914
|
+
});
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
const rows = buildSplitRows(file.body);
|
|
1918
|
+
const language = getLanguageFromPath(file.path);
|
|
1919
|
+
const shouldHighlight =
|
|
1920
|
+
Boolean(language) &&
|
|
1921
|
+
file.body.length <= GIT_DIFF_MAX_HIGHLIGHT_CHARS &&
|
|
1922
|
+
rows.length <= GIT_DIFF_MAX_HIGHLIGHT_ROWS;
|
|
1923
|
+
const maxRows = expanded ? GIT_DIFF_MAX_ROWS_EXPANDED : GIT_DIFF_MAX_ROWS_COLLAPSED;
|
|
1924
|
+
const view = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
1925
|
+
const expandHint = !expanded && view.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
1926
|
+
fileBoxes.push({
|
|
1927
|
+
topLabel,
|
|
1928
|
+
resultComponent: renderBoxedToolResult(theme, view, {
|
|
1929
|
+
dividerLabel: diffDividerLabel(theme, countDiffStats(file.body)),
|
|
1930
|
+
footerLines: [footer],
|
|
1931
|
+
...(expandHint ? { expandHint } : {}),
|
|
1932
|
+
}),
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
let cacheWidth: number | undefined;
|
|
1938
|
+
let cacheLines: string[] | undefined;
|
|
1939
|
+
|
|
1940
|
+
return {
|
|
1941
|
+
invalidate() {
|
|
1942
|
+
cacheWidth = undefined;
|
|
1943
|
+
cacheLines = undefined;
|
|
1944
|
+
for (const box of fileBoxes) box.resultComponent.invalidate();
|
|
1945
|
+
},
|
|
1946
|
+
render(width: number): string[] {
|
|
1947
|
+
if (cacheWidth === width && cacheLines) return cacheLines;
|
|
1948
|
+
const renderedWidth = boxWidth(width);
|
|
1949
|
+
const lines: string[] = [];
|
|
1950
|
+
for (const box of fileBoxes) {
|
|
1951
|
+
lines.push(boxLabeledBorder(theme, "╭", "╮", box.topLabel, undefined, renderedWidth));
|
|
1952
|
+
lines.push(boxBlankLine(theme, renderedWidth));
|
|
1953
|
+
lines.push(...box.resultComponent.render(width));
|
|
1954
|
+
}
|
|
1955
|
+
cacheWidth = width;
|
|
1956
|
+
cacheLines = lines;
|
|
1957
|
+
return lines;
|
|
1958
|
+
},
|
|
1959
|
+
};
|
|
1960
|
+
}
|