diffstalker 0.1.6 → 0.2.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/.github/workflows/release.yml +5 -3
- package/CHANGELOG.md +36 -0
- package/bun.lock +378 -0
- package/dist/App.js +1162 -1
- package/dist/config.js +83 -2
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +525 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +237 -5
- package/dist/index.js +70 -16
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/services/commitService.js +22 -1
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/themes.js +127 -1
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -1
- package/dist/utils/displayRows.js +351 -2
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +236 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +246 -4
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +15 -19
- package/dist/components/BaseBranchPicker.js +0 -1
- package/dist/components/BottomPane.js +0 -1
- package/dist/components/CommitPanel.js +0 -1
- package/dist/components/CompareListView.js +0 -1
- package/dist/components/ExplorerContentView.js +0 -3
- package/dist/components/ExplorerView.js +0 -1
- package/dist/components/FileList.js +0 -1
- package/dist/components/Footer.js +0 -1
- package/dist/components/Header.js +0 -1
- package/dist/components/HistoryView.js +0 -1
- package/dist/components/HotkeysModal.js +0 -1
- package/dist/components/Modal.js +0 -1
- package/dist/components/ScrollableList.js +0 -1
- package/dist/components/ThemePicker.js +0 -1
- package/dist/components/TopPane.js +0 -1
- package/dist/components/UnifiedDiffView.js +0 -1
- package/dist/hooks/useCommitFlow.js +0 -1
- package/dist/hooks/useCompareState.js +0 -1
- package/dist/hooks/useExplorerState.js +0 -9
- package/dist/hooks/useGit.js +0 -1
- package/dist/hooks/useHistoryState.js +0 -1
- package/dist/hooks/useKeymap.js +0 -1
- package/dist/hooks/useLayout.js +0 -1
- package/dist/hooks/useMouse.js +0 -1
- package/dist/hooks/useTerminalSize.js +0 -1
- package/dist/hooks/useWatcher.js +0 -11
package/dist/git/diff.js
CHANGED
|
@@ -1,10 +1,471 @@
|
|
|
1
|
-
import{execSync
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { simpleGit } from 'simple-git';
|
|
3
|
+
export function parseDiffLine(line) {
|
|
4
|
+
if (line.startsWith('diff --git') ||
|
|
5
|
+
line.startsWith('index ') ||
|
|
6
|
+
line.startsWith('---') ||
|
|
7
|
+
line.startsWith('+++') ||
|
|
8
|
+
line.startsWith('new file') ||
|
|
9
|
+
line.startsWith('deleted file')) {
|
|
10
|
+
return { type: 'header', content: line };
|
|
11
|
+
}
|
|
12
|
+
if (line.startsWith('@@')) {
|
|
13
|
+
return { type: 'hunk', content: line };
|
|
14
|
+
}
|
|
15
|
+
if (line.startsWith('+')) {
|
|
16
|
+
return { type: 'addition', content: line };
|
|
17
|
+
}
|
|
18
|
+
if (line.startsWith('-')) {
|
|
19
|
+
return { type: 'deletion', content: line };
|
|
20
|
+
}
|
|
21
|
+
return { type: 'context', content: line };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse a hunk header to extract line numbers.
|
|
25
|
+
* Format: @@ -oldStart,oldCount +newStart,newCount @@
|
|
26
|
+
* Example: @@ -1,5 +1,7 @@ or @@ -10 +10,2 @@
|
|
27
|
+
*/
|
|
28
|
+
export function parseHunkHeader(line) {
|
|
29
|
+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
30
|
+
if (match) {
|
|
31
|
+
return {
|
|
32
|
+
oldStart: parseInt(match[1], 10),
|
|
33
|
+
newStart: parseInt(match[2], 10),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse diff output with line numbers.
|
|
40
|
+
* Tracks line numbers through hunks for proper display.
|
|
41
|
+
*/
|
|
42
|
+
export function parseDiffWithLineNumbers(raw) {
|
|
43
|
+
const lines = raw.split('\n');
|
|
44
|
+
const result = [];
|
|
45
|
+
let oldLineNum = 0;
|
|
46
|
+
let newLineNum = 0;
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (line.startsWith('diff --git') ||
|
|
49
|
+
line.startsWith('index ') ||
|
|
50
|
+
line.startsWith('---') ||
|
|
51
|
+
line.startsWith('+++') ||
|
|
52
|
+
line.startsWith('new file') ||
|
|
53
|
+
line.startsWith('deleted file') ||
|
|
54
|
+
line.startsWith('Binary files') ||
|
|
55
|
+
line.startsWith('similarity index') ||
|
|
56
|
+
line.startsWith('rename from') ||
|
|
57
|
+
line.startsWith('rename to')) {
|
|
58
|
+
result.push({ type: 'header', content: line });
|
|
59
|
+
}
|
|
60
|
+
else if (line.startsWith('@@')) {
|
|
61
|
+
const hunkInfo = parseHunkHeader(line);
|
|
62
|
+
if (hunkInfo) {
|
|
63
|
+
oldLineNum = hunkInfo.oldStart;
|
|
64
|
+
newLineNum = hunkInfo.newStart;
|
|
65
|
+
}
|
|
66
|
+
result.push({ type: 'hunk', content: line });
|
|
67
|
+
}
|
|
68
|
+
else if (line.startsWith('+')) {
|
|
69
|
+
result.push({
|
|
70
|
+
type: 'addition',
|
|
71
|
+
content: line,
|
|
72
|
+
newLineNum: newLineNum++,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else if (line.startsWith('-')) {
|
|
76
|
+
result.push({
|
|
77
|
+
type: 'deletion',
|
|
78
|
+
content: line,
|
|
79
|
+
oldLineNum: oldLineNum++,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Context line (starts with space) or empty line
|
|
84
|
+
result.push({
|
|
85
|
+
type: 'context',
|
|
86
|
+
content: line,
|
|
87
|
+
oldLineNum: oldLineNum++,
|
|
88
|
+
newLineNum: newLineNum++,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
export async function getDiff(repoPath, file, staged = false) {
|
|
95
|
+
const git = simpleGit(repoPath);
|
|
96
|
+
try {
|
|
97
|
+
const args = [];
|
|
98
|
+
if (staged) {
|
|
99
|
+
args.push('--cached');
|
|
100
|
+
}
|
|
101
|
+
if (file) {
|
|
102
|
+
args.push('--', file);
|
|
103
|
+
}
|
|
104
|
+
const raw = await git.diff(args);
|
|
105
|
+
const lines = parseDiffWithLineNumbers(raw);
|
|
106
|
+
return { raw, lines };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { raw: '', lines: [] };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function getDiffForUntracked(repoPath, file) {
|
|
113
|
+
try {
|
|
114
|
+
// For untracked files, show the entire file as additions
|
|
115
|
+
const content = execSync(`cat "${file}"`, { cwd: repoPath, encoding: 'utf-8' });
|
|
116
|
+
const lines = [
|
|
117
|
+
{ type: 'header', content: `diff --git a/${file} b/${file}` },
|
|
118
|
+
{ type: 'header', content: 'new file mode 100644' },
|
|
119
|
+
{ type: 'header', content: `--- /dev/null` },
|
|
120
|
+
{ type: 'header', content: `+++ b/${file}` },
|
|
121
|
+
];
|
|
122
|
+
const contentLines = content.split('\n');
|
|
123
|
+
lines.push({ type: 'hunk', content: `@@ -0,0 +1,${contentLines.length} @@` });
|
|
124
|
+
let lineNum = 1;
|
|
125
|
+
for (const line of contentLines) {
|
|
126
|
+
lines.push({ type: 'addition', content: '+' + line, newLineNum: lineNum++ });
|
|
127
|
+
}
|
|
128
|
+
const raw = lines.map((l) => l.content).join('\n');
|
|
129
|
+
return { raw, lines };
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { raw: '', lines: [] };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function getStagedDiff(repoPath) {
|
|
136
|
+
return getDiff(repoPath, undefined, true);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get candidate base branches for PR comparison.
|
|
140
|
+
* Uses git log to find branches that appear in recent history (likely PR targets).
|
|
141
|
+
*/
|
|
142
|
+
export async function getCandidateBaseBranches(repoPath) {
|
|
143
|
+
const git = simpleGit(repoPath);
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const candidates = [];
|
|
146
|
+
try {
|
|
147
|
+
// Get recent commits with decorations to find branches in our history
|
|
148
|
+
const logOutput = await git.raw(['log', '--oneline', '--decorate=short', '--all', '-n', '200']);
|
|
149
|
+
// Extract remote branch refs from decorations like (origin/main, upstream/feature)
|
|
150
|
+
const refPattern = /\(([^)]+)\)/g;
|
|
151
|
+
for (const line of logOutput.split('\n')) {
|
|
152
|
+
const match = refPattern.exec(line);
|
|
153
|
+
if (match) {
|
|
154
|
+
const refs = match[1].split(',').map((r) => r.trim());
|
|
155
|
+
for (const ref of refs) {
|
|
156
|
+
// Skip HEAD, tags, and local branches - only want remote branches
|
|
157
|
+
if (ref.startsWith('HEAD') || ref.startsWith('tag:') || !ref.includes('/'))
|
|
158
|
+
continue;
|
|
159
|
+
// Clean up "origin/main" from things like "HEAD -> origin/main"
|
|
160
|
+
const cleaned = ref.replace(/^.*-> /, '');
|
|
161
|
+
if (cleaned.includes('/') && !seen.has(cleaned)) {
|
|
162
|
+
seen.add(cleaned);
|
|
163
|
+
candidates.push(cleaned);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
refPattern.lastIndex = 0; // Reset regex state
|
|
168
|
+
}
|
|
169
|
+
// If we found candidates, sort main/master to top, prefer non-origin
|
|
170
|
+
if (candidates.length > 0) {
|
|
171
|
+
candidates.sort((a, b) => {
|
|
172
|
+
const aName = a.split('/').slice(1).join('/');
|
|
173
|
+
const bName = b.split('/').slice(1).join('/');
|
|
174
|
+
const aIsMain = aName === 'main' || aName === 'master';
|
|
175
|
+
const bIsMain = bName === 'main' || bName === 'master';
|
|
176
|
+
// main/master first
|
|
177
|
+
if (aIsMain && !bIsMain)
|
|
178
|
+
return -1;
|
|
179
|
+
if (!aIsMain && bIsMain)
|
|
180
|
+
return 1;
|
|
181
|
+
// Among main/master, prefer non-origin
|
|
182
|
+
if (aIsMain && bIsMain) {
|
|
183
|
+
const aIsOrigin = a.startsWith('origin/');
|
|
184
|
+
const bIsOrigin = b.startsWith('origin/');
|
|
185
|
+
if (aIsOrigin && !bIsOrigin)
|
|
186
|
+
return 1;
|
|
187
|
+
if (!aIsOrigin && bIsOrigin)
|
|
188
|
+
return -1;
|
|
189
|
+
}
|
|
190
|
+
return 0; // Keep discovery order otherwise
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Failed to get branches
|
|
196
|
+
}
|
|
197
|
+
// Return unique candidates (Set deduplication)
|
|
198
|
+
return [...new Set(candidates)];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get the best default base branch for PR comparison.
|
|
202
|
+
*/
|
|
203
|
+
export async function getDefaultBaseBranch(repoPath) {
|
|
204
|
+
const candidates = await getCandidateBaseBranches(repoPath);
|
|
205
|
+
return candidates[0] ?? null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get diff between HEAD and a base ref (for PR-like view).
|
|
209
|
+
* Uses three-dot diff (merge-base) to show only changes on current branch.
|
|
210
|
+
*/
|
|
211
|
+
export async function getDiffBetweenRefs(repoPath, baseRef) {
|
|
212
|
+
const git = simpleGit(repoPath);
|
|
213
|
+
// Get merge-base for three-dot diff
|
|
214
|
+
const mergeBase = await git.raw(['merge-base', baseRef, 'HEAD']);
|
|
215
|
+
const base = mergeBase.trim();
|
|
216
|
+
// Get per-file stats with --numstat
|
|
217
|
+
const numstat = await git.raw(['diff', '--numstat', `${base}...HEAD`]);
|
|
218
|
+
// Get file statuses with --name-status
|
|
219
|
+
const nameStatus = await git.raw(['diff', '--name-status', `${base}...HEAD`]);
|
|
220
|
+
// Get full diff
|
|
221
|
+
const rawDiff = await git.raw(['diff', `${base}...HEAD`]);
|
|
222
|
+
// Parse numstat: "additions deletions filepath" per line
|
|
223
|
+
const numstatLines = numstat
|
|
224
|
+
.trim()
|
|
225
|
+
.split('\n')
|
|
226
|
+
.filter((l) => l);
|
|
227
|
+
const fileStats = new Map();
|
|
228
|
+
for (const line of numstatLines) {
|
|
229
|
+
const parts = line.split('\t');
|
|
230
|
+
if (parts.length >= 3) {
|
|
231
|
+
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
|
|
232
|
+
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
|
|
233
|
+
const filepath = parts.slice(2).join('\t'); // Handle paths with tabs
|
|
234
|
+
fileStats.set(filepath, { additions, deletions });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Parse name-status: "A/M/D/R filepath" per line
|
|
238
|
+
const nameStatusLines = nameStatus
|
|
239
|
+
.trim()
|
|
240
|
+
.split('\n')
|
|
241
|
+
.filter((l) => l);
|
|
242
|
+
const fileStatuses = new Map();
|
|
243
|
+
for (const line of nameStatusLines) {
|
|
244
|
+
const parts = line.split('\t');
|
|
245
|
+
if (parts.length >= 2) {
|
|
246
|
+
const statusChar = parts[0][0];
|
|
247
|
+
const filepath = parts[parts.length - 1]; // Use last part for renamed files
|
|
248
|
+
let status;
|
|
249
|
+
switch (statusChar) {
|
|
250
|
+
case 'A':
|
|
251
|
+
status = 'added';
|
|
252
|
+
break;
|
|
253
|
+
case 'D':
|
|
254
|
+
status = 'deleted';
|
|
255
|
+
break;
|
|
256
|
+
case 'R':
|
|
257
|
+
status = 'renamed';
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
status = 'modified';
|
|
261
|
+
}
|
|
262
|
+
fileStatuses.set(filepath, status);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Split raw diff by file headers
|
|
266
|
+
const fileDiffs = [];
|
|
267
|
+
const diffChunks = rawDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
|
|
268
|
+
for (const chunk of diffChunks) {
|
|
269
|
+
// Extract file path from the diff header
|
|
270
|
+
const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
271
|
+
if (!match)
|
|
272
|
+
continue;
|
|
273
|
+
const filepath = match[1];
|
|
274
|
+
const lines = parseDiffWithLineNumbers(chunk);
|
|
275
|
+
const stats = fileStats.get(filepath) || { additions: 0, deletions: 0 };
|
|
276
|
+
const status = fileStatuses.get(filepath) || 'modified';
|
|
277
|
+
fileDiffs.push({
|
|
278
|
+
path: filepath,
|
|
279
|
+
status,
|
|
280
|
+
additions: stats.additions,
|
|
281
|
+
deletions: stats.deletions,
|
|
282
|
+
diff: { raw: chunk, lines },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Calculate total stats
|
|
286
|
+
let totalAdditions = 0;
|
|
287
|
+
let totalDeletions = 0;
|
|
288
|
+
for (const file of fileDiffs) {
|
|
289
|
+
totalAdditions += file.additions;
|
|
290
|
+
totalDeletions += file.deletions;
|
|
291
|
+
}
|
|
292
|
+
// Get uncommitted count from status
|
|
293
|
+
const status = await git.status();
|
|
294
|
+
const uncommittedCount = status.files.length;
|
|
295
|
+
// Get commits between base and HEAD
|
|
296
|
+
const log = await git.log({ from: base, to: 'HEAD' });
|
|
297
|
+
const commits = log.all.map((entry) => ({
|
|
298
|
+
hash: entry.hash,
|
|
299
|
+
shortHash: entry.hash.slice(0, 7),
|
|
300
|
+
message: entry.message.split('\n')[0],
|
|
301
|
+
author: entry.author_name,
|
|
302
|
+
date: new Date(entry.date),
|
|
303
|
+
refs: entry.refs || '',
|
|
304
|
+
}));
|
|
305
|
+
return {
|
|
306
|
+
baseBranch: baseRef,
|
|
307
|
+
stats: {
|
|
308
|
+
filesChanged: fileDiffs.length,
|
|
309
|
+
additions: totalAdditions,
|
|
310
|
+
deletions: totalDeletions,
|
|
311
|
+
},
|
|
312
|
+
files: fileDiffs,
|
|
313
|
+
commits,
|
|
314
|
+
uncommittedCount,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Get diff for a specific commit.
|
|
319
|
+
* Shows the changes introduced by that commit.
|
|
320
|
+
*/
|
|
321
|
+
export async function getCommitDiff(repoPath, hash) {
|
|
322
|
+
const git = simpleGit(repoPath);
|
|
323
|
+
try {
|
|
324
|
+
// git show <hash> --format="" gives just the diff without commit metadata
|
|
325
|
+
const raw = await git.raw(['show', hash, '--format=']);
|
|
326
|
+
const lines = parseDiffWithLineNumbers(raw);
|
|
327
|
+
return { raw, lines };
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return { raw: '', lines: [] };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Get PR diff that includes uncommitted changes (staged + unstaged).
|
|
335
|
+
* Merges committed diff with working tree changes.
|
|
336
|
+
*/
|
|
337
|
+
export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
|
|
338
|
+
const git = simpleGit(repoPath);
|
|
339
|
+
// Get the committed PR diff first
|
|
340
|
+
const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
|
|
341
|
+
// Get uncommitted changes (both staged and unstaged)
|
|
342
|
+
const stagedRaw = await git.diff(['--cached', '--numstat']);
|
|
343
|
+
const unstagedRaw = await git.diff(['--numstat']);
|
|
344
|
+
const stagedDiff = await git.diff(['--cached']);
|
|
345
|
+
const unstagedDiff = await git.diff([]);
|
|
346
|
+
// Parse uncommitted file stats
|
|
347
|
+
const uncommittedFiles = new Map();
|
|
348
|
+
// Parse staged files
|
|
349
|
+
for (const line of stagedRaw
|
|
350
|
+
.trim()
|
|
351
|
+
.split('\n')
|
|
352
|
+
.filter((l) => l)) {
|
|
353
|
+
const parts = line.split('\t');
|
|
354
|
+
if (parts.length >= 3) {
|
|
355
|
+
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
|
|
356
|
+
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
|
|
357
|
+
const filepath = parts.slice(2).join('\t');
|
|
358
|
+
uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Parse unstaged files
|
|
362
|
+
for (const line of unstagedRaw
|
|
363
|
+
.trim()
|
|
364
|
+
.split('\n')
|
|
365
|
+
.filter((l) => l)) {
|
|
366
|
+
const parts = line.split('\t');
|
|
367
|
+
if (parts.length >= 3) {
|
|
368
|
+
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
|
|
369
|
+
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
|
|
370
|
+
const filepath = parts.slice(2).join('\t');
|
|
371
|
+
const existing = uncommittedFiles.get(filepath);
|
|
372
|
+
if (existing) {
|
|
373
|
+
existing.additions += additions;
|
|
374
|
+
existing.deletions += deletions;
|
|
375
|
+
existing.unstaged = true;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
uncommittedFiles.set(filepath, { additions, deletions, staged: false, unstaged: true });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Get status for file status detection
|
|
383
|
+
const status = await git.status();
|
|
384
|
+
const statusMap = new Map();
|
|
385
|
+
for (const file of status.files) {
|
|
386
|
+
if (file.index === 'A' || file.working_dir === '?') {
|
|
387
|
+
statusMap.set(file.path, 'added');
|
|
388
|
+
}
|
|
389
|
+
else if (file.index === 'D' || file.working_dir === 'D') {
|
|
390
|
+
statusMap.set(file.path, 'deleted');
|
|
391
|
+
}
|
|
392
|
+
else if (file.index === 'R') {
|
|
393
|
+
statusMap.set(file.path, 'renamed');
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
statusMap.set(file.path, 'modified');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Split uncommitted diffs by file
|
|
400
|
+
const uncommittedFileDiffs = [];
|
|
401
|
+
const combinedDiff = stagedDiff + unstagedDiff;
|
|
402
|
+
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
|
+
const processedFiles = new Set();
|
|
405
|
+
for (const chunk of diffChunks) {
|
|
406
|
+
const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
407
|
+
if (!match)
|
|
408
|
+
continue;
|
|
409
|
+
const filepath = match[1];
|
|
410
|
+
if (processedFiles.has(filepath))
|
|
411
|
+
continue;
|
|
412
|
+
processedFiles.add(filepath);
|
|
413
|
+
const lines = parseDiffWithLineNumbers(chunk);
|
|
414
|
+
const fileStats = uncommittedFiles.get(filepath) || { additions: 0, deletions: 0 };
|
|
415
|
+
const fileStatus = statusMap.get(filepath) || 'modified';
|
|
416
|
+
uncommittedFileDiffs.push({
|
|
417
|
+
path: filepath,
|
|
418
|
+
status: fileStatus,
|
|
419
|
+
additions: fileStats.additions,
|
|
420
|
+
deletions: fileStats.deletions,
|
|
421
|
+
diff: { raw: chunk, lines },
|
|
422
|
+
isUncommitted: true,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Merge: keep committed files, add/replace with uncommitted
|
|
426
|
+
const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
|
|
427
|
+
const mergedFiles = [];
|
|
428
|
+
// Add committed files first
|
|
429
|
+
for (const file of committedDiff.files) {
|
|
430
|
+
const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
|
|
431
|
+
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
|
+
mergedFiles.push(file);
|
|
436
|
+
mergedFiles.push(uncommittedFile);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
mergedFiles.push(file);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Add uncommitted-only files (not in committed diff)
|
|
443
|
+
for (const file of uncommittedFileDiffs) {
|
|
444
|
+
if (!committedFilePaths.has(file.path)) {
|
|
445
|
+
mergedFiles.push(file);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Calculate new totals including uncommitted
|
|
449
|
+
let totalAdditions = 0;
|
|
450
|
+
let totalDeletions = 0;
|
|
451
|
+
const seenPaths = new Set();
|
|
452
|
+
for (const file of mergedFiles) {
|
|
453
|
+
// Count unique file paths for stats
|
|
454
|
+
if (!seenPaths.has(file.path)) {
|
|
455
|
+
seenPaths.add(file.path);
|
|
456
|
+
}
|
|
457
|
+
totalAdditions += file.additions;
|
|
458
|
+
totalDeletions += file.deletions;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
baseBranch: committedDiff.baseBranch,
|
|
462
|
+
stats: {
|
|
463
|
+
filesChanged: seenPaths.size,
|
|
464
|
+
additions: totalAdditions,
|
|
465
|
+
deletions: totalDeletions,
|
|
466
|
+
},
|
|
467
|
+
files: mergedFiles,
|
|
468
|
+
commits: committedDiff.commits,
|
|
469
|
+
uncommittedCount: committedDiff.uncommittedCount,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
/**
|
|
3
|
+
* Check which files from a list are ignored by git.
|
|
4
|
+
* Uses `git check-ignore` to determine ignored files.
|
|
5
|
+
*/
|
|
6
|
+
export async function getIgnoredFiles(repoPath, files) {
|
|
7
|
+
if (files.length === 0)
|
|
8
|
+
return new Set();
|
|
9
|
+
const git = simpleGit(repoPath);
|
|
10
|
+
const ignoredFiles = new Set();
|
|
11
|
+
const batchSize = 100;
|
|
12
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
13
|
+
const batch = files.slice(i, i + batchSize);
|
|
14
|
+
try {
|
|
15
|
+
const result = await git.raw(['check-ignore', ...batch]);
|
|
16
|
+
const ignored = result
|
|
17
|
+
.trim()
|
|
18
|
+
.split('\n')
|
|
19
|
+
.filter((f) => f.length > 0);
|
|
20
|
+
for (const f of ignored) {
|
|
21
|
+
ignoredFiles.add(f);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// check-ignore exits with code 1 if no files are ignored, which throws
|
|
26
|
+
// Just continue to next batch
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return ignoredFiles;
|
|
30
|
+
}
|