diffstalker 0.2.0 → 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/.github/workflows/release.yml +8 -0
- package/README.md +43 -35
- package/bun.lock +82 -3
- package/dist/App.js +555 -552
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +228 -0
- package/dist/MouseHandlers.js +192 -0
- package/dist/core/ExplorerStateManager.js +423 -78
- package/dist/core/GitStateManager.js +260 -119
- package/dist/git/diff.js +102 -17
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +60 -53
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +39 -4
- package/dist/ui/PaneRenderers.js +76 -0
- package/dist/ui/modals/FileFinder.js +193 -0
- 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 +123 -80
- package/dist/ui/widgets/DiffView.js +228 -180
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +148 -43
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -11
- package/dist/ui/widgets/Header.js +17 -52
- 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/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +15 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/metrics/v0.2.2.json +229 -0
- package/package.json +9 -2
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -246
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 {
|
|
@@ -302,6 +397,8 @@ export async function getDiffBetweenRefs(repoPath, baseRef) {
|
|
|
302
397
|
date: new Date(entry.date),
|
|
303
398
|
refs: entry.refs || '',
|
|
304
399
|
}));
|
|
400
|
+
// Sort files alphabetically by path
|
|
401
|
+
fileDiffs.sort((a, b) => a.path.localeCompare(b.path));
|
|
305
402
|
return {
|
|
306
403
|
baseBranch: baseRef,
|
|
307
404
|
stats: {
|
|
@@ -336,16 +433,13 @@ export async function getCommitDiff(repoPath, hash) {
|
|
|
336
433
|
*/
|
|
337
434
|
export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
338
435
|
const git = simpleGit(repoPath);
|
|
339
|
-
// Get the committed PR diff first
|
|
340
436
|
const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
|
|
341
|
-
// Get uncommitted changes (both staged and unstaged)
|
|
342
437
|
const stagedRaw = await git.diff(['--cached', '--numstat']);
|
|
343
438
|
const unstagedRaw = await git.diff(['--numstat']);
|
|
344
439
|
const stagedDiff = await git.diff(['--cached']);
|
|
345
440
|
const unstagedDiff = await git.diff([]);
|
|
346
|
-
// Parse uncommitted file stats
|
|
441
|
+
// Parse uncommitted file stats from numstat output
|
|
347
442
|
const uncommittedFiles = new Map();
|
|
348
|
-
// Parse staged files
|
|
349
443
|
for (const line of stagedRaw
|
|
350
444
|
.trim()
|
|
351
445
|
.split('\n')
|
|
@@ -358,7 +452,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
358
452
|
uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
|
|
359
453
|
}
|
|
360
454
|
}
|
|
361
|
-
// Parse unstaged files
|
|
362
455
|
for (const line of unstagedRaw
|
|
363
456
|
.trim()
|
|
364
457
|
.split('\n')
|
|
@@ -379,7 +472,7 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
379
472
|
}
|
|
380
473
|
}
|
|
381
474
|
}
|
|
382
|
-
//
|
|
475
|
+
// Build status map from git status
|
|
383
476
|
const status = await git.status();
|
|
384
477
|
const statusMap = new Map();
|
|
385
478
|
for (const file of status.files) {
|
|
@@ -400,7 +493,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
400
493
|
const uncommittedFileDiffs = [];
|
|
401
494
|
const combinedDiff = stagedDiff + unstagedDiff;
|
|
402
495
|
const diffChunks = combinedDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
|
|
403
|
-
// Track files we've already processed (avoid duplicates if file has both staged and unstaged)
|
|
404
496
|
const processedFiles = new Set();
|
|
405
497
|
for (const chunk of diffChunks) {
|
|
406
498
|
const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
@@ -425,13 +517,9 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
425
517
|
// Merge: keep committed files, add/replace with uncommitted
|
|
426
518
|
const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
|
|
427
519
|
const mergedFiles = [];
|
|
428
|
-
// Add committed files first
|
|
429
520
|
for (const file of committedDiff.files) {
|
|
430
521
|
const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
|
|
431
522
|
if (uncommittedFile) {
|
|
432
|
-
// If file has both committed and uncommitted changes, combine them
|
|
433
|
-
// For simplicity, we'll show committed + uncommitted as separate entries
|
|
434
|
-
// with the uncommitted one marked
|
|
435
523
|
mergedFiles.push(file);
|
|
436
524
|
mergedFiles.push(uncommittedFile);
|
|
437
525
|
}
|
|
@@ -439,24 +527,21 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
|
439
527
|
mergedFiles.push(file);
|
|
440
528
|
}
|
|
441
529
|
}
|
|
442
|
-
// Add uncommitted-only files (not in committed diff)
|
|
443
530
|
for (const file of uncommittedFileDiffs) {
|
|
444
531
|
if (!committedFilePaths.has(file.path)) {
|
|
445
532
|
mergedFiles.push(file);
|
|
446
533
|
}
|
|
447
534
|
}
|
|
448
|
-
// Calculate
|
|
535
|
+
// Calculate totals
|
|
449
536
|
let totalAdditions = 0;
|
|
450
537
|
let totalDeletions = 0;
|
|
451
538
|
const seenPaths = new Set();
|
|
452
539
|
for (const file of mergedFiles) {
|
|
453
|
-
|
|
454
|
-
if (!seenPaths.has(file.path)) {
|
|
455
|
-
seenPaths.add(file.path);
|
|
456
|
-
}
|
|
540
|
+
seenPaths.add(file.path);
|
|
457
541
|
totalAdditions += file.additions;
|
|
458
542
|
totalDeletions += file.deletions;
|
|
459
543
|
}
|
|
544
|
+
mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
460
545
|
return {
|
|
461
546
|
baseBranch: committedDiff.baseBranch,
|
|
462
547
|
stats: {
|
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
|
+
}
|