diffstalker 0.2.1 → 0.2.2
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/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +378 -129
- package/dist/KeyBindings.js +59 -9
- package/dist/MouseHandlers.js +56 -20
- package/dist/core/ExplorerStateManager.js +17 -38
- package/dist/core/GitStateManager.js +111 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +53 -47
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/ui/PaneRenderers.js +33 -13
- package/dist/ui/modals/FileFinder.js +26 -65
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +14 -6
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/package.json +6 -2
package/dist/git/diff.js
CHANGED
|
@@ -41,6 +41,11 @@ export function parseHunkHeader(line) {
|
|
|
41
41
|
*/
|
|
42
42
|
export function parseDiffWithLineNumbers(raw) {
|
|
43
43
|
const lines = raw.split('\n');
|
|
44
|
+
// Remove trailing empty string from the final newline in git output,
|
|
45
|
+
// otherwise it gets parsed as a phantom context line on the last hunk
|
|
46
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
47
|
+
lines.pop();
|
|
48
|
+
}
|
|
44
49
|
const result = [];
|
|
45
50
|
let oldLineNum = 0;
|
|
46
51
|
let newLineNum = 0;
|
|
@@ -91,6 +96,96 @@ export function parseDiffWithLineNumbers(raw) {
|
|
|
91
96
|
}
|
|
92
97
|
return result;
|
|
93
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Count the number of hunks in a raw diff string.
|
|
101
|
+
* A hunk starts with a line beginning with '@@'.
|
|
102
|
+
*/
|
|
103
|
+
export function countHunks(rawDiff) {
|
|
104
|
+
if (!rawDiff)
|
|
105
|
+
return 0;
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const line of rawDiff.split('\n')) {
|
|
108
|
+
if (line.startsWith('@@'))
|
|
109
|
+
count++;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Extract a valid single-hunk patch from a raw diff.
|
|
115
|
+
* Includes all file headers (diff --git, index, new file mode,
|
|
116
|
+
* rename from/to, ---, +++) plus the Nth @@ hunk and its lines
|
|
117
|
+
* (including '' markers).
|
|
118
|
+
* Returns null if hunkIndex is out of range.
|
|
119
|
+
*/
|
|
120
|
+
export function extractHunkPatch(rawDiff, hunkIndex) {
|
|
121
|
+
if (!rawDiff)
|
|
122
|
+
return null;
|
|
123
|
+
const lines = rawDiff.split('\n');
|
|
124
|
+
// Collect file headers (everything before the first @@)
|
|
125
|
+
const headers = [];
|
|
126
|
+
let firstHunkLine = -1;
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
if (lines[i].startsWith('@@')) {
|
|
129
|
+
firstHunkLine = i;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
headers.push(lines[i]);
|
|
133
|
+
}
|
|
134
|
+
if (firstHunkLine === -1)
|
|
135
|
+
return null;
|
|
136
|
+
// Find the Nth @@ line
|
|
137
|
+
let hunkCount = -1;
|
|
138
|
+
let hunkStart = -1;
|
|
139
|
+
for (let i = firstHunkLine; i < lines.length; i++) {
|
|
140
|
+
if (lines[i].startsWith('@@')) {
|
|
141
|
+
hunkCount++;
|
|
142
|
+
if (hunkCount === hunkIndex) {
|
|
143
|
+
hunkStart = i;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (hunkStart === -1)
|
|
149
|
+
return null;
|
|
150
|
+
// Collect from that @@ until the next @@ or end-of-content
|
|
151
|
+
const hunkLines = [lines[hunkStart]];
|
|
152
|
+
for (let i = hunkStart + 1; i < lines.length; i++) {
|
|
153
|
+
if (lines[i].startsWith('@@') || lines[i].startsWith('diff --git'))
|
|
154
|
+
break;
|
|
155
|
+
hunkLines.push(lines[i]);
|
|
156
|
+
}
|
|
157
|
+
// Remove trailing empty line if present (artifact of split)
|
|
158
|
+
while (hunkLines.length > 1 && hunkLines[hunkLines.length - 1] === '') {
|
|
159
|
+
hunkLines.pop();
|
|
160
|
+
}
|
|
161
|
+
const patch = [...headers, ...hunkLines].join('\n') + '\n';
|
|
162
|
+
return patch;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Count the number of hunks per file in a multi-file raw diff string.
|
|
166
|
+
* Returns a map of file path -> hunk count.
|
|
167
|
+
*/
|
|
168
|
+
export function countHunksPerFile(rawDiff) {
|
|
169
|
+
const result = new Map();
|
|
170
|
+
if (!rawDiff)
|
|
171
|
+
return result;
|
|
172
|
+
let currentFile = null;
|
|
173
|
+
for (const line of rawDiff.split('\n')) {
|
|
174
|
+
if (line.startsWith('diff --git')) {
|
|
175
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
176
|
+
if (match) {
|
|
177
|
+
currentFile = match[1];
|
|
178
|
+
if (!result.has(currentFile)) {
|
|
179
|
+
result.set(currentFile, 0);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (line.startsWith('@@') && currentFile) {
|
|
184
|
+
result.set(currentFile, (result.get(currentFile) ?? 0) + 1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
94
189
|
export async function getDiff(repoPath, file, staged = false) {
|
|
95
190
|
const git = simpleGit(repoPath);
|
|
96
191
|
try {
|
|
@@ -338,16 +433,13 @@ export async function getCommitDiff(repoPath, hash) {
|
|
|
338
433
|
*/
|
|
339
434
|
export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
340
435
|
const git = simpleGit(repoPath);
|
|
341
|
-
// Get the committed PR diff first
|
|
342
436
|
const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
|
|
343
|
-
// Get uncommitted changes (both staged and unstaged)
|
|
344
437
|
const stagedRaw = await git.diff(['--cached', '--numstat']);
|
|
345
438
|
const unstagedRaw = await git.diff(['--numstat']);
|
|
346
439
|
const stagedDiff = await git.diff(['--cached']);
|
|
347
440
|
const unstagedDiff = await git.diff([]);
|
|
348
|
-
// Parse uncommitted file stats
|
|
441
|
+
// Parse uncommitted file stats from numstat output
|
|
349
442
|
const uncommittedFiles = new Map();
|
|
350
|
-
// Parse staged files
|
|
351
443
|
for (const line of stagedRaw
|
|
352
444
|
.trim()
|
|
353
445
|
.split('\n')
|
|
@@ -360,7 +452,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
360
452
|
uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
|
|
361
453
|
}
|
|
362
454
|
}
|
|
363
|
-
// Parse unstaged files
|
|
364
455
|
for (const line of unstagedRaw
|
|
365
456
|
.trim()
|
|
366
457
|
.split('\n')
|
|
@@ -381,7 +472,7 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
381
472
|
}
|
|
382
473
|
}
|
|
383
474
|
}
|
|
384
|
-
//
|
|
475
|
+
// Build status map from git status
|
|
385
476
|
const status = await git.status();
|
|
386
477
|
const statusMap = new Map();
|
|
387
478
|
for (const file of status.files) {
|
|
@@ -402,7 +493,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
402
493
|
const uncommittedFileDiffs = [];
|
|
403
494
|
const combinedDiff = stagedDiff + unstagedDiff;
|
|
404
495
|
const diffChunks = combinedDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
|
|
405
|
-
// Track files we've already processed (avoid duplicates if file has both staged and unstaged)
|
|
406
496
|
const processedFiles = new Set();
|
|
407
497
|
for (const chunk of diffChunks) {
|
|
408
498
|
const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
@@ -427,13 +517,9 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
427
517
|
// Merge: keep committed files, add/replace with uncommitted
|
|
428
518
|
const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
|
|
429
519
|
const mergedFiles = [];
|
|
430
|
-
// Add committed files first
|
|
431
520
|
for (const file of committedDiff.files) {
|
|
432
521
|
const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
|
|
433
522
|
if (uncommittedFile) {
|
|
434
|
-
// If file has both committed and uncommitted changes, combine them
|
|
435
|
-
// For simplicity, we'll show committed + uncommitted as separate entries
|
|
436
|
-
// with the uncommitted one marked
|
|
437
523
|
mergedFiles.push(file);
|
|
438
524
|
mergedFiles.push(uncommittedFile);
|
|
439
525
|
}
|
|
@@ -441,25 +527,20 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
441
527
|
mergedFiles.push(file);
|
|
442
528
|
}
|
|
443
529
|
}
|
|
444
|
-
// Add uncommitted-only files (not in committed diff)
|
|
445
530
|
for (const file of uncommittedFileDiffs) {
|
|
446
531
|
if (!committedFilePaths.has(file.path)) {
|
|
447
532
|
mergedFiles.push(file);
|
|
448
533
|
}
|
|
449
534
|
}
|
|
450
|
-
// Calculate
|
|
535
|
+
// Calculate totals
|
|
451
536
|
let totalAdditions = 0;
|
|
452
537
|
let totalDeletions = 0;
|
|
453
538
|
const seenPaths = new Set();
|
|
454
539
|
for (const file of mergedFiles) {
|
|
455
|
-
|
|
456
|
-
if (!seenPaths.has(file.path)) {
|
|
457
|
-
seenPaths.add(file.path);
|
|
458
|
-
}
|
|
540
|
+
seenPaths.add(file.path);
|
|
459
541
|
totalAdditions += file.additions;
|
|
460
542
|
totalDeletions += file.deletions;
|
|
461
543
|
}
|
|
462
|
-
// Sort files alphabetically by path
|
|
463
544
|
mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
464
545
|
return {
|
|
465
546
|
baseBranch: committedDiff.baseBranch,
|
package/dist/git/status.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { simpleGit } from 'simple-git';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import * as path from 'node:path';
|
|
4
5
|
import { getIgnoredFiles } from './ignoreUtils.js';
|
|
@@ -60,62 +61,12 @@ export async function getStatus(repoPath) {
|
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
const status = await git.status();
|
|
63
|
-
|
|
64
|
-
// Process staged files
|
|
65
|
-
for (const file of status.staged) {
|
|
66
|
-
files.push({
|
|
67
|
-
path: file,
|
|
68
|
-
status: 'added',
|
|
69
|
-
staged: true,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
// Process modified staged files
|
|
73
|
-
for (const file of status.modified) {
|
|
74
|
-
// Check if it's in the index (staged)
|
|
75
|
-
const existingStaged = files.find((f) => f.path === file && f.staged);
|
|
76
|
-
if (!existingStaged) {
|
|
77
|
-
files.push({
|
|
78
|
-
path: file,
|
|
79
|
-
status: 'modified',
|
|
80
|
-
staged: false,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
// Process deleted files
|
|
85
|
-
for (const file of status.deleted) {
|
|
86
|
-
files.push({
|
|
87
|
-
path: file,
|
|
88
|
-
status: 'deleted',
|
|
89
|
-
staged: false,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
// Process untracked files
|
|
93
|
-
for (const file of status.not_added) {
|
|
94
|
-
files.push({
|
|
95
|
-
path: file,
|
|
96
|
-
status: 'untracked',
|
|
97
|
-
staged: false,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
// Process renamed files
|
|
101
|
-
for (const file of status.renamed) {
|
|
102
|
-
files.push({
|
|
103
|
-
path: file.to,
|
|
104
|
-
originalPath: file.from,
|
|
105
|
-
status: 'renamed',
|
|
106
|
-
staged: true,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
// Use the files array from status for more accurate staging info
|
|
110
|
-
// The status.files array has detailed index/working_dir info
|
|
64
|
+
// Build processed file list, filtering ignored files
|
|
111
65
|
const processedFiles = [];
|
|
112
66
|
const seen = new Set();
|
|
113
|
-
// Collect untracked files to check if they're ignored
|
|
114
67
|
const untrackedPaths = status.files.filter((f) => f.working_dir === '?').map((f) => f.path);
|
|
115
|
-
// Get the set of ignored files
|
|
116
68
|
const ignoredFiles = await getIgnoredFiles(repoPath, untrackedPaths);
|
|
117
69
|
for (const file of status.files) {
|
|
118
|
-
// Skip ignored files (marked with '!' in either column, or detected by check-ignore)
|
|
119
70
|
if (file.index === '!' || file.working_dir === '!' || ignoredFiles.has(file.path)) {
|
|
120
71
|
continue;
|
|
121
72
|
}
|
|
@@ -123,7 +74,6 @@ export async function getStatus(repoPath) {
|
|
|
123
74
|
if (seen.has(key))
|
|
124
75
|
continue;
|
|
125
76
|
seen.add(key);
|
|
126
|
-
// Staged changes (index column)
|
|
127
77
|
if (file.index && file.index !== ' ' && file.index !== '?') {
|
|
128
78
|
processedFiles.push({
|
|
129
79
|
path: file.path,
|
|
@@ -131,7 +81,6 @@ export async function getStatus(repoPath) {
|
|
|
131
81
|
staged: true,
|
|
132
82
|
});
|
|
133
83
|
}
|
|
134
|
-
// Unstaged changes (working_dir column)
|
|
135
84
|
if (file.working_dir && file.working_dir !== ' ') {
|
|
136
85
|
processedFiles.push({
|
|
137
86
|
path: file.path,
|
|
@@ -147,7 +96,6 @@ export async function getStatus(repoPath) {
|
|
|
147
96
|
]);
|
|
148
97
|
const stagedStats = parseNumstat(stagedNumstat);
|
|
149
98
|
const unstagedStats = parseNumstat(unstagedNumstat);
|
|
150
|
-
// Apply stats to files
|
|
151
99
|
for (const file of processedFiles) {
|
|
152
100
|
const stats = file.staged ? stagedStats.get(file.path) : unstagedStats.get(file.path);
|
|
153
101
|
if (stats) {
|
|
@@ -218,6 +166,20 @@ export async function getHeadMessage(repoPath) {
|
|
|
218
166
|
return '';
|
|
219
167
|
}
|
|
220
168
|
}
|
|
169
|
+
export function stageHunk(repoPath, patch) {
|
|
170
|
+
execFileSync('git', ['apply', '--cached', '--unidiff-zero'], {
|
|
171
|
+
cwd: repoPath,
|
|
172
|
+
input: patch,
|
|
173
|
+
encoding: 'utf-8',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
export function unstageHunk(repoPath, patch) {
|
|
177
|
+
execFileSync('git', ['apply', '--cached', '--reverse', '--unidiff-zero'], {
|
|
178
|
+
cwd: repoPath,
|
|
179
|
+
input: patch,
|
|
180
|
+
encoding: 'utf-8',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
221
183
|
export async function getCommitHistory(repoPath, count = 50) {
|
|
222
184
|
const git = simpleGit(repoPath);
|
|
223
185
|
try {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
const FIXTURES_DIR = path.resolve(import.meta.dirname, '../../test-fixtures');
|
|
5
|
+
/**
|
|
6
|
+
* Create a fixture git repo with initial config.
|
|
7
|
+
*/
|
|
8
|
+
export function createFixtureRepo(name) {
|
|
9
|
+
const repoPath = path.join(FIXTURES_DIR, name);
|
|
10
|
+
fs.mkdirSync(repoPath, { recursive: true });
|
|
11
|
+
gitExec(repoPath, 'init --initial-branch=main');
|
|
12
|
+
gitExec(repoPath, 'config user.email "test@test.com"');
|
|
13
|
+
gitExec(repoPath, 'config user.name "Test User"');
|
|
14
|
+
return repoPath;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Remove a fixture repo directory.
|
|
18
|
+
*/
|
|
19
|
+
export function removeFixtureRepo(name) {
|
|
20
|
+
const repoPath = path.join(FIXTURES_DIR, name);
|
|
21
|
+
fs.rmSync(repoPath, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Write a file inside a fixture repo, creating parent directories as needed.
|
|
25
|
+
*/
|
|
26
|
+
export function writeFixtureFile(repoPath, filePath, content) {
|
|
27
|
+
const fullPath = path.join(repoPath, filePath);
|
|
28
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(fullPath, content);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run a git command in a fixture repo.
|
|
33
|
+
*/
|
|
34
|
+
export function gitExec(repoPath, command) {
|
|
35
|
+
return execSync(`git ${command}`, {
|
|
36
|
+
cwd: repoPath,
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a bare remote repo and a cloned working repo for branch comparison tests.
|
|
43
|
+
* Returns { remotePath, repoPath }.
|
|
44
|
+
*/
|
|
45
|
+
export function createRepoWithRemote(name) {
|
|
46
|
+
const remotePath = path.join(FIXTURES_DIR, `${name}-remote`);
|
|
47
|
+
const repoPath = path.join(FIXTURES_DIR, name);
|
|
48
|
+
// Create bare remote
|
|
49
|
+
fs.mkdirSync(remotePath, { recursive: true });
|
|
50
|
+
gitExec(remotePath, 'init --bare --initial-branch=main');
|
|
51
|
+
// Clone it
|
|
52
|
+
execSync(`git clone "${remotePath}" "${repoPath}"`, {
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
55
|
+
});
|
|
56
|
+
// Configure cloned repo
|
|
57
|
+
gitExec(repoPath, 'config user.email "test@test.com"');
|
|
58
|
+
gitExec(repoPath, 'config user.name "Test User"');
|
|
59
|
+
return { remotePath, repoPath };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Clean up both a repo and its remote.
|
|
63
|
+
*/
|
|
64
|
+
export function removeRepoWithRemote(name) {
|
|
65
|
+
removeFixtureRepo(name);
|
|
66
|
+
removeFixtureRepo(`${name}-remote`);
|
|
67
|
+
}
|