@abelfubu/dv 0.1.0
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/dist/ansi-html.d.ts +42 -0
- package/dist/ansi-html.d.ts.map +1 -0
- package/dist/ansi-html.js +327 -0
- package/dist/ansi-output.d.ts +22 -0
- package/dist/ansi-output.d.ts.map +1 -0
- package/dist/ansi-output.js +154 -0
- package/dist/balance-delimiters.d.ts +25 -0
- package/dist/balance-delimiters.d.ts.map +1 -0
- package/dist/balance-delimiters.js +539 -0
- package/dist/balance-delimiters.test.d.ts +2 -0
- package/dist/balance-delimiters.test.d.ts.map +1 -0
- package/dist/balance-delimiters.test.js +1029 -0
- package/dist/cli-copy-notification.test.d.ts +2 -0
- package/dist/cli-copy-notification.test.d.ts.map +1 -0
- package/dist/cli-copy-notification.test.js +80 -0
- package/dist/cli-scroll.test.d.ts +2 -0
- package/dist/cli-scroll.test.d.ts.map +1 -0
- package/dist/cli-scroll.test.js +283 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +976 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +128 -0
- package/dist/components/diff-view.d.ts +32 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.js +123 -0
- package/dist/components/diff-view.test.d.ts +5 -0
- package/dist/components/diff-view.test.d.ts.map +1 -0
- package/dist/components/diff-view.test.js +312 -0
- package/dist/components/directory-tree-view.d.ts +33 -0
- package/dist/components/directory-tree-view.d.ts.map +1 -0
- package/dist/components/directory-tree-view.js +262 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +5 -0
- package/dist/components/toast.d.ts +21 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/components/toast.js +47 -0
- package/dist/diff-cursor-utils.d.ts +20 -0
- package/dist/diff-cursor-utils.d.ts.map +1 -0
- package/dist/diff-cursor-utils.js +105 -0
- package/dist/diff-cursor-utils.test.d.ts +2 -0
- package/dist/diff-cursor-utils.test.d.ts.map +1 -0
- package/dist/diff-cursor-utils.test.js +40 -0
- package/dist/diff-surface-copy.d.ts +23 -0
- package/dist/diff-surface-copy.d.ts.map +1 -0
- package/dist/diff-surface-copy.js +64 -0
- package/dist/diff-surface-copy.test.d.ts +5 -0
- package/dist/diff-surface-copy.test.d.ts.map +1 -0
- package/dist/diff-surface-copy.test.js +142 -0
- package/dist/diff-utils.d.ts +196 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +682 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +727 -0
- package/dist/directory-tree.d.ts +72 -0
- package/dist/directory-tree.d.ts.map +1 -0
- package/dist/directory-tree.js +161 -0
- package/dist/directory-tree.test.d.ts +2 -0
- package/dist/directory-tree.test.d.ts.map +1 -0
- package/dist/directory-tree.test.js +383 -0
- package/dist/dropdown.d.ts +26 -0
- package/dist/dropdown.d.ts.map +1 -0
- package/dist/dropdown.js +172 -0
- package/dist/dropdown.test.d.ts +2 -0
- package/dist/dropdown.test.d.ts.map +1 -0
- package/dist/dropdown.test.js +106 -0
- package/dist/filter-submodule.e2e.test.d.ts +2 -0
- package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
- package/dist/filter-submodule.e2e.test.js +109 -0
- package/dist/hooks/use-copy-selection.d.ts +29 -0
- package/dist/hooks/use-copy-selection.d.ts.map +1 -0
- package/dist/hooks/use-copy-selection.js +46 -0
- package/dist/kv-codec.d.ts +16 -0
- package/dist/kv-codec.d.ts.map +1 -0
- package/dist/kv-codec.js +36 -0
- package/dist/license.d.ts +14 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +63 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +78 -0
- package/dist/monochrome.d.ts +34 -0
- package/dist/monochrome.d.ts.map +1 -0
- package/dist/monochrome.js +613 -0
- package/dist/monotone.d.ts +22 -0
- package/dist/monotone.d.ts.map +1 -0
- package/dist/monotone.js +185 -0
- package/dist/parsers-config.d.ts +19 -0
- package/dist/parsers-config.d.ts.map +1 -0
- package/dist/parsers-config.js +271 -0
- package/dist/patch-terminal-dimensions.d.ts +2 -0
- package/dist/patch-terminal-dimensions.d.ts.map +1 -0
- package/dist/patch-terminal-dimensions.js +45 -0
- package/dist/stdin-pager.test.d.ts +2 -0
- package/dist/stdin-pager.test.d.ts.map +1 -0
- package/dist/stdin-pager.test.js +497 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +48 -0
- package/dist/themes/github.json +247 -0
- package/dist/themes.d.ts +59 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +248 -0
- package/dist/tree-icons.d.ts +4 -0
- package/dist/tree-icons.d.ts.map +1 -0
- package/dist/tree-icons.js +18 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +13 -0
- package/dist/web-utils.d.ts +56 -0
- package/dist/web-utils.d.ts.map +1 -0
- package/dist/web-utils.js +363 -0
- package/package.json +37 -0
- package/public/jetbrains-mono-nerd.ttf +0 -0
- package/public/jetbrains-mono-nerd.woff2 +0 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// Shared utilities for git diff processing across CLI commands.
|
|
2
|
+
// Builds git commands, parses diff files, detects filetypes for syntax highlighting,
|
|
3
|
+
// and provides helpers for unified/split view mode selection.
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { buildDirectoryTree } from "./directory-tree.js";
|
|
8
|
+
/**
|
|
9
|
+
* Check if the current directory is inside a git repository.
|
|
10
|
+
* If not, print a friendly error message and exit.
|
|
11
|
+
*/
|
|
12
|
+
export function ensureGitRepo() {
|
|
13
|
+
try {
|
|
14
|
+
execSync("git rev-parse --is-inside-work-tree", { stdio: "pipe" });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.error("fatal: not a git repository (or any parent up to mount point /)");
|
|
18
|
+
console.error("");
|
|
19
|
+
console.error("Run critique inside a git repository.");
|
|
20
|
+
process.exit(128);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Get the absolute path to the git repository root.
|
|
25
|
+
*/
|
|
26
|
+
export function getGitRepoRoot() {
|
|
27
|
+
try {
|
|
28
|
+
return execSync("git rev-parse --show-toplevel", { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return process.cwd();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Strip submodule status lines from git diff output.
|
|
36
|
+
* git diff --submodule=diff adds various status lines that the diff parser doesn't understand:
|
|
37
|
+
* - "Submodule name hash1..hash2:" (header before submodule diff)
|
|
38
|
+
* - "Submodule name contains modified content"
|
|
39
|
+
* - "Submodule name contains untracked content"
|
|
40
|
+
* - "Submodule name (new commits)"
|
|
41
|
+
* - "Submodule name (commits not present)"
|
|
42
|
+
*/
|
|
43
|
+
export function stripSubmoduleHeaders(diffOutput) {
|
|
44
|
+
return diffOutput
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((line) => {
|
|
47
|
+
// Match lines like "Submodule errore 1bf6fc8..d746b25:"
|
|
48
|
+
if (line.match(/^Submodule \S+ [a-f0-9]+\.\.[a-f0-9]+:?$/))
|
|
49
|
+
return false;
|
|
50
|
+
// Match lines like "Submodule unframer contains modified content"
|
|
51
|
+
if (line.match(/^Submodule \S+ contains (modified|untracked) content$/))
|
|
52
|
+
return false;
|
|
53
|
+
// Match lines like "Submodule name (new commits)" or "(commits not present)"
|
|
54
|
+
if (line.match(/^Submodule \S+ \(.*\)$/))
|
|
55
|
+
return false;
|
|
56
|
+
return true;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Preprocess raw git diff output to handle rename/copy detection.
|
|
62
|
+
*
|
|
63
|
+
* The `diff` npm package's parsePatch does not understand git's rename/copy
|
|
64
|
+
* headers (similarity index, rename from/to, copy from/to). For pure renames
|
|
65
|
+
* (100% similarity, no content changes), it produces broken entries because
|
|
66
|
+
* there are no ---/+++ or @@ lines for it to parse.
|
|
67
|
+
*
|
|
68
|
+
* This function:
|
|
69
|
+
* 1. Injects synthetic --- and +++ headers for pure renames/copies so parsePatch
|
|
70
|
+
* creates proper entries with correct filenames
|
|
71
|
+
* 2. Extracts rename/copy metadata (type, from, to, similarity) for each file section
|
|
72
|
+
*
|
|
73
|
+
* @returns processedDiff: diff string safe for parsePatch, renameInfo: metadata per file index
|
|
74
|
+
*/
|
|
75
|
+
export function preprocessDiff(rawDiff) {
|
|
76
|
+
const renameInfo = new Map();
|
|
77
|
+
// Split into per-file sections at "diff --git" boundaries
|
|
78
|
+
const lines = rawDiff.split("\n");
|
|
79
|
+
const sections = [];
|
|
80
|
+
let currentSection = null;
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.startsWith("diff --git ")) {
|
|
83
|
+
if (currentSection) {
|
|
84
|
+
sections.push({ startIdx: sections.length, lines: currentSection });
|
|
85
|
+
}
|
|
86
|
+
currentSection = [line];
|
|
87
|
+
}
|
|
88
|
+
else if (currentSection) {
|
|
89
|
+
currentSection.push(line);
|
|
90
|
+
}
|
|
91
|
+
// Lines before the first "diff --git" (e.g. commit metadata from git show) are ignored
|
|
92
|
+
}
|
|
93
|
+
if (currentSection) {
|
|
94
|
+
sections.push({ startIdx: sections.length, lines: currentSection });
|
|
95
|
+
}
|
|
96
|
+
// Some callers may pass patch text produced by `diff`'s formatPatch(), which
|
|
97
|
+
// uses "Index:" headers instead of "diff --git". In that case, do not
|
|
98
|
+
// drop the whole payload: return it as-is so parsePatch can still parse hunks.
|
|
99
|
+
if (sections.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
processedDiff: rawDiff,
|
|
102
|
+
renameInfo,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const outputSections = [];
|
|
106
|
+
for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
|
|
107
|
+
const section = sections[sectionIdx];
|
|
108
|
+
const sectionLines = section.lines;
|
|
109
|
+
// Extract rename/copy metadata from this section
|
|
110
|
+
let renameFrom;
|
|
111
|
+
let renameTo;
|
|
112
|
+
let copyFrom;
|
|
113
|
+
let copyTo;
|
|
114
|
+
let similarity;
|
|
115
|
+
let hasFileHeaders = false;
|
|
116
|
+
for (const line of sectionLines) {
|
|
117
|
+
if (line.startsWith("--- "))
|
|
118
|
+
hasFileHeaders = true;
|
|
119
|
+
const renameFromMatch = line.match(/^rename from (.+)$/);
|
|
120
|
+
if (renameFromMatch)
|
|
121
|
+
renameFrom = renameFromMatch[1];
|
|
122
|
+
const renameToMatch = line.match(/^rename to (.+)$/);
|
|
123
|
+
if (renameToMatch)
|
|
124
|
+
renameTo = renameToMatch[1];
|
|
125
|
+
const copyFromMatch = line.match(/^copy from (.+)$/);
|
|
126
|
+
if (copyFromMatch)
|
|
127
|
+
copyFrom = copyFromMatch[1];
|
|
128
|
+
const copyToMatch = line.match(/^copy to (.+)$/);
|
|
129
|
+
if (copyToMatch)
|
|
130
|
+
copyTo = copyToMatch[1];
|
|
131
|
+
const similarityMatch = line.match(/^similarity index (\d+)%$/);
|
|
132
|
+
if (similarityMatch)
|
|
133
|
+
similarity = parseInt(similarityMatch[1], 10);
|
|
134
|
+
}
|
|
135
|
+
// Store rename/copy metadata
|
|
136
|
+
if (renameFrom && renameTo) {
|
|
137
|
+
renameInfo.set(sectionIdx, {
|
|
138
|
+
type: "rename",
|
|
139
|
+
from: renameFrom,
|
|
140
|
+
to: renameTo,
|
|
141
|
+
similarity: similarity ?? 100,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else if (copyFrom && copyTo) {
|
|
145
|
+
renameInfo.set(sectionIdx, {
|
|
146
|
+
type: "copy",
|
|
147
|
+
from: copyFrom,
|
|
148
|
+
to: copyTo,
|
|
149
|
+
similarity: similarity ?? 100,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// For pure renames/copies (no --- +++ headers), inject synthetic headers
|
|
153
|
+
// so parsePatch creates a proper entry with filenames
|
|
154
|
+
if (!hasFileHeaders && (renameFrom && renameTo)) {
|
|
155
|
+
outputSections.push([...sectionLines, `--- ${renameFrom}`, `+++ ${renameTo}`].join("\n"));
|
|
156
|
+
}
|
|
157
|
+
else if (!hasFileHeaders && (copyFrom && copyTo)) {
|
|
158
|
+
outputSections.push([...sectionLines, `--- ${copyFrom}`, `+++ ${copyTo}`].join("\n"));
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
outputSections.push(sectionLines.join("\n"));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
processedDiff: outputSections.join("\n"),
|
|
166
|
+
renameInfo,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Parse git diff output with rename/copy detection support.
|
|
171
|
+
* Preprocesses the diff for pure renames, delegates to parsePatch from the `diff` package,
|
|
172
|
+
* and enriches results with rename metadata.
|
|
173
|
+
*
|
|
174
|
+
* Use this instead of calling parsePatch directly when processing git diff -M output.
|
|
175
|
+
*
|
|
176
|
+
* Generic to preserve the concrete type returned by parsePatch (e.g. StructuredPatch).
|
|
177
|
+
*/
|
|
178
|
+
export function parseGitDiffFiles(rawDiff, parsePatch) {
|
|
179
|
+
const { processedDiff, renameInfo } = preprocessDiff(rawDiff);
|
|
180
|
+
const files = parsePatch(processedDiff);
|
|
181
|
+
// Enrich files with rename metadata
|
|
182
|
+
return files.map((file, index) => {
|
|
183
|
+
const info = renameInfo.get(index);
|
|
184
|
+
if (!info)
|
|
185
|
+
return file;
|
|
186
|
+
return {
|
|
187
|
+
...file,
|
|
188
|
+
renameFrom: info.from,
|
|
189
|
+
renameTo: info.to,
|
|
190
|
+
similarity: info.similarity,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export const IGNORED_FILES = [
|
|
195
|
+
"pnpm-lock.yaml",
|
|
196
|
+
"package-lock.json",
|
|
197
|
+
"yarn.lock",
|
|
198
|
+
"bun.lockb",
|
|
199
|
+
"Cargo.lock",
|
|
200
|
+
"poetry.lock",
|
|
201
|
+
"Gemfile.lock",
|
|
202
|
+
"composer.lock",
|
|
203
|
+
"snapshot.json",
|
|
204
|
+
"worker-configuration.d.ts",
|
|
205
|
+
];
|
|
206
|
+
/** Default number of context lines around each diff hunk */
|
|
207
|
+
export const DEFAULT_CONTEXT_LINES = 6;
|
|
208
|
+
/**
|
|
209
|
+
* Normalize file filter patterns from both --filter and positional args after --.
|
|
210
|
+
*/
|
|
211
|
+
export function getFilterPatterns(options) {
|
|
212
|
+
const filterOptions = options.filter
|
|
213
|
+
? Array.isArray(options.filter)
|
|
214
|
+
? options.filter
|
|
215
|
+
: [options.filter]
|
|
216
|
+
: [];
|
|
217
|
+
const positionalFilters = options.positionalFilters || [];
|
|
218
|
+
return [...new Set([...filterOptions, ...positionalFilters].filter((pattern) => pattern.length > 0))];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Check whether a filepath matches any user-provided file filter glob.
|
|
222
|
+
* No patterns means "match everything".
|
|
223
|
+
*/
|
|
224
|
+
export function matchesFileFilters(filePath, patterns) {
|
|
225
|
+
if (patterns.length === 0)
|
|
226
|
+
return true;
|
|
227
|
+
return patterns.some((rawPattern) => {
|
|
228
|
+
const pattern = rawPattern.startsWith("./") ? rawPattern.slice(2) : rawPattern;
|
|
229
|
+
if (pattern === "." || pattern === "")
|
|
230
|
+
return true;
|
|
231
|
+
// Keep compatibility with existing git pathspec behavior for plain paths:
|
|
232
|
+
// - "src" should match "src/**"
|
|
233
|
+
// - "src/" should match descendants under src/
|
|
234
|
+
// - "src/file.ts" should match that exact file
|
|
235
|
+
const hasGlobMagic = /[*?[\]{}!]/.test(pattern);
|
|
236
|
+
if (!hasGlobMagic) {
|
|
237
|
+
if (pattern.endsWith("/")) {
|
|
238
|
+
return filePath.startsWith(pattern);
|
|
239
|
+
}
|
|
240
|
+
return filePath === pattern || filePath.startsWith(pattern + "/");
|
|
241
|
+
}
|
|
242
|
+
const glob = new Bun.Glob(pattern);
|
|
243
|
+
return glob.match(filePath);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Apply critique --filter globs to already-parsed diff files.
|
|
248
|
+
* This is used after appending submodule diffs, where git pathspec filters are
|
|
249
|
+
* no longer sufficient.
|
|
250
|
+
*/
|
|
251
|
+
export function filterParsedFilesByPatterns(files, options) {
|
|
252
|
+
const patterns = getFilterPatterns(options);
|
|
253
|
+
if (patterns.length === 0)
|
|
254
|
+
return files;
|
|
255
|
+
return files.filter((file) => matchesFileFilters(getFileName(file), patterns));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build git command string based on options
|
|
259
|
+
*/
|
|
260
|
+
export function buildGitCommand(options) {
|
|
261
|
+
const contextArg = `-U${options.context ?? DEFAULT_CONTEXT_LINES}`;
|
|
262
|
+
// Show full submodule diffs instead of just commit hashes
|
|
263
|
+
const submoduleArg = "--submodule=diff";
|
|
264
|
+
// Detect renames instead of showing full delete+add
|
|
265
|
+
const renameArg = "-M";
|
|
266
|
+
// Combine --filter options with positional args after --
|
|
267
|
+
const filters = getFilterPatterns(options);
|
|
268
|
+
// Use single quotes to prevent shell expansion of $ in paths like d.$owner.$repo.$.tsx
|
|
269
|
+
const filterArg = filters.length > 0
|
|
270
|
+
? `-- ${filters.map((f) => `'${f}'`).join(" ")}`
|
|
271
|
+
: "";
|
|
272
|
+
// If --commit contains range syntax (A..B or A...B), treat it as a base ref
|
|
273
|
+
// instead. git show with ranges outputs commit metadata interleaved with diffs
|
|
274
|
+
// that parsePatch cannot parse. Redirecting to base reuses the existing range
|
|
275
|
+
// handling below (two-dot and three-dot parsing).
|
|
276
|
+
if (options.commit?.includes("..")) {
|
|
277
|
+
options = { ...options, base: options.commit, commit: undefined };
|
|
278
|
+
}
|
|
279
|
+
if (options.staged) {
|
|
280
|
+
return `git diff --cached --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
281
|
+
}
|
|
282
|
+
if (options.commit) {
|
|
283
|
+
return `git show ${options.commit} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
284
|
+
}
|
|
285
|
+
// Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
|
|
286
|
+
if (options.base && options.head) {
|
|
287
|
+
return `git diff ${options.base}...${options.head} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
288
|
+
}
|
|
289
|
+
// Detect range syntax in single base argument (e.g., "origin/main...HEAD" or "main..feature")
|
|
290
|
+
if (options.base && !options.head) {
|
|
291
|
+
// Three-dot syntax: A...B (merge-base to B, like GitHub PRs)
|
|
292
|
+
const threeDotsMatch = options.base.match(/^(.+)\.\.\.(.+)$/);
|
|
293
|
+
if (threeDotsMatch) {
|
|
294
|
+
const [, rangeBase, rangeHead] = threeDotsMatch;
|
|
295
|
+
return `git diff ${rangeBase}...${rangeHead} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
296
|
+
}
|
|
297
|
+
// Two-dot syntax: A..B (commits in B not in A)
|
|
298
|
+
const twoDotsMatch = options.base.match(/^(.+)\.\.(.+)$/);
|
|
299
|
+
if (twoDotsMatch) {
|
|
300
|
+
const [, rangeBase, rangeHead] = twoDotsMatch;
|
|
301
|
+
return `git diff ${rangeBase}..${rangeHead} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Single ref: compare ref to working tree (like git diff)
|
|
305
|
+
if (options.base) {
|
|
306
|
+
return `git diff ${options.base} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
307
|
+
}
|
|
308
|
+
// Default (no args): ignore submodules here — dirty submodule diffs are fetched
|
|
309
|
+
// separately via buildSubmoduleDiffCommand() to avoid showing committed submodule
|
|
310
|
+
// ref changes that have no actual uncommitted content.
|
|
311
|
+
// Untracked files are appended synthetically in the caller to avoid mutating the
|
|
312
|
+
// git index (git add -N would modify .git/index).
|
|
313
|
+
return `git diff --no-prefix ${renameArg} --ignore-submodules=all ${contextArg} ${filterArg}`.trim();
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Get submodule paths that have dirty working trees (uncommitted changes).
|
|
317
|
+
* Returns only submodules with actual uncommitted modifications, not those
|
|
318
|
+
* that merely point to a different commit than what the parent repo recorded.
|
|
319
|
+
*
|
|
320
|
+
* Uses `git submodule status` which prefixes each line with:
|
|
321
|
+
* - ' ' (space): submodule matches recorded commit and is clean
|
|
322
|
+
* - '+': submodule is at a different commit than recorded
|
|
323
|
+
* - '-': submodule is not initialized
|
|
324
|
+
* - 'U': submodule has merge conflicts
|
|
325
|
+
*
|
|
326
|
+
* A submodule with '+' prefix AND a trailing dirty marker (e.g. " (modified content)")
|
|
327
|
+
* or one where `git status --porcelain` inside it is non-empty has dirty changes.
|
|
328
|
+
*/
|
|
329
|
+
export function getDirtySubmodulePaths() {
|
|
330
|
+
try {
|
|
331
|
+
// git submodule foreach runs a command in each initialized submodule.
|
|
332
|
+
// We check if the submodule has any uncommitted changes (modified, staged, or untracked).
|
|
333
|
+
// $displaypath gives us the relative path from the parent repo root.
|
|
334
|
+
const output = execSync(`git submodule foreach --quiet 'if [ -n "$(git status --porcelain)" ]; then echo "$displaypath"; fi'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
335
|
+
return output
|
|
336
|
+
.trim()
|
|
337
|
+
.split("\n")
|
|
338
|
+
.filter((line) => line.length > 0);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// No submodules, or git command failed — return empty
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Build a git diff command that only shows diffs for specific submodule paths.
|
|
347
|
+
* Used to get the actual file-level diffs inside dirty submodules.
|
|
348
|
+
*/
|
|
349
|
+
export function buildSubmoduleDiffCommand(submodulePaths, options) {
|
|
350
|
+
const contextArg = `-U${options.context ?? DEFAULT_CONTEXT_LINES}`;
|
|
351
|
+
const renameArg = "-M";
|
|
352
|
+
const submoduleArg = "--submodule=diff";
|
|
353
|
+
const pathArgs = submodulePaths.map((p) => `'${p}'`).join(" ");
|
|
354
|
+
return `git diff --no-prefix ${renameArg} ${submoduleArg} ${contextArg} -- ${pathArgs}`.trim();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Get paths of untracked files in the working tree.
|
|
358
|
+
*/
|
|
359
|
+
export function getUntrackedFilePaths() {
|
|
360
|
+
try {
|
|
361
|
+
const output = execSync("git ls-files --others --exclude-standard", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
362
|
+
return output
|
|
363
|
+
.trim()
|
|
364
|
+
.split("\n")
|
|
365
|
+
.filter((line) => line.length > 0);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Check if a buffer contains binary (non-text) content.
|
|
373
|
+
* Uses the same heuristic as git: look for null bytes in the first 8000 bytes.
|
|
374
|
+
*/
|
|
375
|
+
function isBinaryContent(buffer) {
|
|
376
|
+
const sample = buffer.slice(0, 8000);
|
|
377
|
+
for (let i = 0; i < sample.length; i++) {
|
|
378
|
+
if (sample[i] === 0)
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Generate a synthetic git diff for an untracked file.
|
|
385
|
+
* Produces output equivalent to `git add -N <file> && git diff <file>`.
|
|
386
|
+
*/
|
|
387
|
+
export function buildUntrackedFileDiff(filePath) {
|
|
388
|
+
const repoRoot = getGitRepoRoot();
|
|
389
|
+
const fullPath = join(repoRoot, filePath);
|
|
390
|
+
let content;
|
|
391
|
+
try {
|
|
392
|
+
const buffer = fs.readFileSync(fullPath);
|
|
393
|
+
if (isBinaryContent(buffer)) {
|
|
394
|
+
// Binary files: emit a minimal diff without hunks
|
|
395
|
+
return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n`;
|
|
396
|
+
}
|
|
397
|
+
content = buffer.toString("utf-8");
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return "";
|
|
401
|
+
}
|
|
402
|
+
// Handle trailing newline: git diff strips it from the last line
|
|
403
|
+
const endsWithNewline = content.endsWith("\n");
|
|
404
|
+
const lines = content.split("\n");
|
|
405
|
+
// split("\n") on trailing newline gives an extra empty string at the end
|
|
406
|
+
if (endsWithNewline) {
|
|
407
|
+
lines.pop();
|
|
408
|
+
}
|
|
409
|
+
const lineCount = lines.length;
|
|
410
|
+
if (lineCount === 0) {
|
|
411
|
+
return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n`;
|
|
412
|
+
}
|
|
413
|
+
const hunkHeader = `@@ -0,0 +1,${lineCount} @@`;
|
|
414
|
+
const diffLines = lines.map((line) => "+" + line).join("\n");
|
|
415
|
+
// If file doesn't end with newline, mark it
|
|
416
|
+
const noNewlineMarker = endsWithNewline ? "" : "\n\";
|
|
417
|
+
return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n${hunkHeader}\n${diffLines}${noNewlineMarker}`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Common git diff prefixes that appear in external diffs (git diff, gh pr diff, etc.)
|
|
421
|
+
* - a/, b/: standard git diff prefixes
|
|
422
|
+
* - w/: worktree prefix (git diff develop | dv)
|
|
423
|
+
*/
|
|
424
|
+
const COMMON_DIFF_PREFIXES = ["a/", "b/", "w/"];
|
|
425
|
+
/**
|
|
426
|
+
* Strip git diff prefixes from file paths.
|
|
427
|
+
* External diffs (e.g. gh pr diff) include these prefixes, but internal git
|
|
428
|
+
* commands use --no-prefix. Normalizing ensures consistent display.
|
|
429
|
+
*/
|
|
430
|
+
function stripDiffPrefix(path) {
|
|
431
|
+
if (!path)
|
|
432
|
+
return path;
|
|
433
|
+
if (path === "/dev/null")
|
|
434
|
+
return path;
|
|
435
|
+
if (COMMON_DIFF_PREFIXES.some((prefix) => path.startsWith(prefix))) {
|
|
436
|
+
return path.slice(2);
|
|
437
|
+
}
|
|
438
|
+
return path;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Get file status from parsed diff file
|
|
442
|
+
* - added: oldFileName is /dev/null (new file)
|
|
443
|
+
* - deleted: newFileName is /dev/null (removed file)
|
|
444
|
+
* - renamed: file has renameFrom/renameTo metadata, or oldFileName !== newFileName
|
|
445
|
+
* (with --no-prefix, different filenames means rename since there's no a/ b/ prefix)
|
|
446
|
+
* - modified: both files exist with same name (changed file)
|
|
447
|
+
*/
|
|
448
|
+
export function getFileStatus(file) {
|
|
449
|
+
const oldName = stripDiffPrefix(file.oldFileName);
|
|
450
|
+
const newName = stripDiffPrefix(file.newFileName);
|
|
451
|
+
if (!oldName || oldName === "/dev/null")
|
|
452
|
+
return "added";
|
|
453
|
+
if (!newName || newName === "/dev/null")
|
|
454
|
+
return "deleted";
|
|
455
|
+
// Explicit rename metadata from preprocessDiff
|
|
456
|
+
if (file.renameFrom && file.renameTo)
|
|
457
|
+
return "renamed";
|
|
458
|
+
// With --no-prefix, different filenames means rename
|
|
459
|
+
if (oldName !== newName)
|
|
460
|
+
return "renamed";
|
|
461
|
+
return "modified";
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Get filename from parsed diff file, handling /dev/null for new/deleted files.
|
|
465
|
+
* For renames, returns the new name (destination).
|
|
466
|
+
*/
|
|
467
|
+
export function getFileName(file) {
|
|
468
|
+
// For renames, prefer the renameTo metadata (always clean, no prefix)
|
|
469
|
+
if (file.renameTo)
|
|
470
|
+
return file.renameTo;
|
|
471
|
+
const newName = stripDiffPrefix(file.newFileName);
|
|
472
|
+
const oldName = stripDiffPrefix(file.oldFileName);
|
|
473
|
+
// Filter out /dev/null which appears for new/deleted files
|
|
474
|
+
if (newName && newName !== "/dev/null")
|
|
475
|
+
return newName;
|
|
476
|
+
if (oldName && oldName !== "/dev/null")
|
|
477
|
+
return oldName;
|
|
478
|
+
return "unknown";
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Get the old filename for display purposes (e.g., "old-name.ts -> new-name.ts").
|
|
482
|
+
* Returns undefined if the file was not renamed.
|
|
483
|
+
*/
|
|
484
|
+
export function getOldFileName(file) {
|
|
485
|
+
if (file.renameFrom && file.renameTo)
|
|
486
|
+
return file.renameFrom;
|
|
487
|
+
const oldName = stripDiffPrefix(file.oldFileName);
|
|
488
|
+
const newName = stripDiffPrefix(file.newFileName);
|
|
489
|
+
if (oldName && newName && oldName !== newName && oldName !== "/dev/null" && newName !== "/dev/null") {
|
|
490
|
+
return oldName;
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Count additions and deletions from hunks
|
|
496
|
+
*/
|
|
497
|
+
export function countChanges(hunks) {
|
|
498
|
+
let additions = 0;
|
|
499
|
+
let deletions = 0;
|
|
500
|
+
for (const hunk of hunks) {
|
|
501
|
+
for (const line of hunk.lines) {
|
|
502
|
+
if (line.startsWith("+"))
|
|
503
|
+
additions++;
|
|
504
|
+
if (line.startsWith("-"))
|
|
505
|
+
deletions++;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { additions, deletions };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Determine view mode based on changes and terminal width
|
|
512
|
+
* @param splitThreshold - minimum cols for split view (default 100 for TUI, 150 for web)
|
|
513
|
+
*/
|
|
514
|
+
export function getViewMode(additions, deletions, cols, splitThreshold = 100) {
|
|
515
|
+
// Use unified view for fully added or fully deleted files (one side would be empty in split view)
|
|
516
|
+
const isFullyAdded = additions > 0 && deletions === 0;
|
|
517
|
+
const isFullyDeleted = deletions > 0 && additions === 0;
|
|
518
|
+
const useUnifiedForFile = isFullyAdded || isFullyDeleted;
|
|
519
|
+
if (useUnifiedForFile)
|
|
520
|
+
return "unified";
|
|
521
|
+
return cols >= splitThreshold ? "split" : "unified";
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Filter and sort parsed diff files, add rawDiff
|
|
525
|
+
*/
|
|
526
|
+
export function processFiles(files, formatPatch) {
|
|
527
|
+
const filteredFiles = files.filter((file) => {
|
|
528
|
+
const fileName = getFileName(file);
|
|
529
|
+
const baseName = fileName.split("/").pop() || "";
|
|
530
|
+
if (IGNORED_FILES.includes(baseName) || baseName.endsWith(".lock")) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
const totalLines = file.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0);
|
|
534
|
+
return totalLines <= 6000;
|
|
535
|
+
});
|
|
536
|
+
const treeFiles = filteredFiles.map((file, index) => {
|
|
537
|
+
const { additions, deletions } = countChanges(file.hunks);
|
|
538
|
+
return {
|
|
539
|
+
path: getFileName(file),
|
|
540
|
+
status: getFileStatus(file),
|
|
541
|
+
additions,
|
|
542
|
+
deletions,
|
|
543
|
+
fileIndex: index,
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
const treeFileOrder = buildDirectoryTree(treeFiles)
|
|
547
|
+
.filter((node) => node.isFile && node.fileIndex !== undefined)
|
|
548
|
+
.map((node) => node.fileIndex);
|
|
549
|
+
const seenIndexes = new Set();
|
|
550
|
+
const sortedFiles = [];
|
|
551
|
+
for (const index of treeFileOrder) {
|
|
552
|
+
if (seenIndexes.has(index))
|
|
553
|
+
continue;
|
|
554
|
+
const file = filteredFiles[index];
|
|
555
|
+
if (!file)
|
|
556
|
+
continue;
|
|
557
|
+
seenIndexes.add(index);
|
|
558
|
+
sortedFiles.push(file);
|
|
559
|
+
}
|
|
560
|
+
// Defensive fallback: keep any unmatched files in original order.
|
|
561
|
+
// This should be rare, but avoids dropping files if tree metadata and
|
|
562
|
+
// parsed file list ever diverge.
|
|
563
|
+
for (let index = 0; index < filteredFiles.length; index++) {
|
|
564
|
+
if (seenIndexes.has(index))
|
|
565
|
+
continue;
|
|
566
|
+
const file = filteredFiles[index];
|
|
567
|
+
if (!file)
|
|
568
|
+
continue;
|
|
569
|
+
sortedFiles.push(file);
|
|
570
|
+
}
|
|
571
|
+
// Add rawDiff for each file
|
|
572
|
+
return sortedFiles.map((file) => ({
|
|
573
|
+
...file,
|
|
574
|
+
rawDiff: formatPatch(file),
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Detect filetype from filename for syntax highlighting
|
|
579
|
+
* Maps to tree-sitter parsers available in @opentuah/core and parsers-config.ts
|
|
580
|
+
*/
|
|
581
|
+
export function detectFiletype(filePath) {
|
|
582
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
583
|
+
switch (ext) {
|
|
584
|
+
// TypeScript parser handles TS, TSX, JS, JSX (it's a superset)
|
|
585
|
+
case "ts":
|
|
586
|
+
case "tsx":
|
|
587
|
+
case "js":
|
|
588
|
+
case "jsx":
|
|
589
|
+
case "mjs":
|
|
590
|
+
case "cjs":
|
|
591
|
+
case "mts":
|
|
592
|
+
case "cts":
|
|
593
|
+
return "typescript";
|
|
594
|
+
case "json":
|
|
595
|
+
case "jsonc":
|
|
596
|
+
case "json5":
|
|
597
|
+
return "json";
|
|
598
|
+
case "md":
|
|
599
|
+
case "mdx":
|
|
600
|
+
case "mkd":
|
|
601
|
+
case "mkdn":
|
|
602
|
+
case "mdown":
|
|
603
|
+
case "markdown":
|
|
604
|
+
return "markdown";
|
|
605
|
+
case "zig":
|
|
606
|
+
return "zig";
|
|
607
|
+
// Languages from parsers-config.ts
|
|
608
|
+
case "py":
|
|
609
|
+
case "pyw":
|
|
610
|
+
case "pyi":
|
|
611
|
+
return "python";
|
|
612
|
+
case "rs":
|
|
613
|
+
return "rust";
|
|
614
|
+
case "go":
|
|
615
|
+
return "go";
|
|
616
|
+
case "cpp":
|
|
617
|
+
case "cc":
|
|
618
|
+
case "cxx":
|
|
619
|
+
case "hpp":
|
|
620
|
+
case "hxx":
|
|
621
|
+
case "hh":
|
|
622
|
+
case "tpp":
|
|
623
|
+
case "ipp":
|
|
624
|
+
case "inl":
|
|
625
|
+
case "h":
|
|
626
|
+
return "cpp";
|
|
627
|
+
case "cs":
|
|
628
|
+
return "csharp";
|
|
629
|
+
case "sh":
|
|
630
|
+
case "bash":
|
|
631
|
+
case "zsh":
|
|
632
|
+
case "ksh":
|
|
633
|
+
return "bash";
|
|
634
|
+
case "c":
|
|
635
|
+
return "c";
|
|
636
|
+
case "java":
|
|
637
|
+
return "java";
|
|
638
|
+
case "rb":
|
|
639
|
+
case "rake":
|
|
640
|
+
case "gemspec":
|
|
641
|
+
return "ruby";
|
|
642
|
+
case "php":
|
|
643
|
+
return "php";
|
|
644
|
+
case "scala":
|
|
645
|
+
case "sc":
|
|
646
|
+
return "scala";
|
|
647
|
+
case "html":
|
|
648
|
+
case "htm":
|
|
649
|
+
case "xhtml":
|
|
650
|
+
case "xml":
|
|
651
|
+
case "svg":
|
|
652
|
+
return "html";
|
|
653
|
+
case "yaml":
|
|
654
|
+
case "yml":
|
|
655
|
+
return "yaml";
|
|
656
|
+
case "hs":
|
|
657
|
+
case "lhs":
|
|
658
|
+
return "haskell";
|
|
659
|
+
case "css":
|
|
660
|
+
case "scss":
|
|
661
|
+
case "less":
|
|
662
|
+
return "css";
|
|
663
|
+
case "jl":
|
|
664
|
+
return "julia";
|
|
665
|
+
case "ml":
|
|
666
|
+
case "mli":
|
|
667
|
+
return "ocaml";
|
|
668
|
+
case "clj":
|
|
669
|
+
case "cljs":
|
|
670
|
+
case "cljc":
|
|
671
|
+
case "edn":
|
|
672
|
+
return "clojure";
|
|
673
|
+
case "swift":
|
|
674
|
+
return "swift";
|
|
675
|
+
case "nix":
|
|
676
|
+
return "nix";
|
|
677
|
+
case "prisma":
|
|
678
|
+
return "prisma";
|
|
679
|
+
default:
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff-utils.test.d.ts","sourceRoot":"","sources":["../src/diff-utils.test.ts"],"names":[],"mappings":""}
|