diffwatch 2.0.3 → 2.0.5
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/README.md +16 -0
- package/bin/diffwatch.js +36 -8
- package/package.json +11 -6
- package/src/App.tsx +420 -0
- package/src/components/DiffViewer.tsx +258 -0
- package/src/components/FileList.tsx +95 -0
- package/src/components/HistoryViewer.tsx +170 -0
- package/src/components/StatusBar.tsx +27 -0
- package/src/constants.ts +1 -0
- package/src/index.tsx +106 -0
- package/src/utils/git.ts +402 -0
- package/src/version.ts +1 -0
- package/dist/diffwatch.exe +0 -0
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as fsPromisesMod from 'fs/promises';
|
|
3
|
+
import * as pathMod from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import simpleGit from 'simple-git';
|
|
6
|
+
|
|
7
|
+
// Allow dependency injection for testing
|
|
8
|
+
interface FsPromises {
|
|
9
|
+
access: typeof fsPromisesMod.access;
|
|
10
|
+
readFile: typeof fsPromisesMod.readFile;
|
|
11
|
+
unlink: typeof fsPromisesMod.unlink;
|
|
12
|
+
stat: typeof fsPromisesMod.stat;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Path {
|
|
16
|
+
isAbsolute: typeof pathMod.isAbsolute;
|
|
17
|
+
join: typeof pathMod.join;
|
|
18
|
+
resolve: typeof pathMod.resolve;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Default implementations using actual modules
|
|
22
|
+
const defaultFsPromises: FsPromises = fsPromisesMod;
|
|
23
|
+
const defaultPath: Path = pathMod;
|
|
24
|
+
|
|
25
|
+
export interface FileStatus {
|
|
26
|
+
path: string;
|
|
27
|
+
status: 'modified' | 'new' | 'deleted' | 'renamed' | 'unknown' | 'unstaged' | 'unchanged' | 'ignored';
|
|
28
|
+
mtime: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runGit(args: string[], cwd: string = process.cwd()): Promise<string> {
|
|
32
|
+
const git = simpleGit(cwd);
|
|
33
|
+
return git.raw(args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getChangedFiles(
|
|
37
|
+
cwd: string = process.cwd(),
|
|
38
|
+
fsPromises: FsPromises = defaultFsPromises,
|
|
39
|
+
path: Path = defaultPath
|
|
40
|
+
): Promise<FileStatus[]> {
|
|
41
|
+
try {
|
|
42
|
+
const git = simpleGit(cwd);
|
|
43
|
+
const status = await git.status();
|
|
44
|
+
const root = await getRepoRoot(cwd);
|
|
45
|
+
|
|
46
|
+
const uniquePaths = new Set<string>();
|
|
47
|
+
const fileList: FileStatus[] = [];
|
|
48
|
+
|
|
49
|
+
// Helper function to add file if not already added
|
|
50
|
+
const addFileIfUnique = (filePath: string, fileStatus: FileStatus['status']) => {
|
|
51
|
+
if (!uniquePaths.has(filePath)) {
|
|
52
|
+
uniquePaths.add(filePath);
|
|
53
|
+
fileList.push({
|
|
54
|
+
path: filePath,
|
|
55
|
+
status: fileStatus,
|
|
56
|
+
mtime: new Date(0)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Parse status.files array - this is more reliable than precomputed arrays
|
|
62
|
+
// Git returns compound status strings like "AD" (Added + Deleted), "AM", "MD", etc.
|
|
63
|
+
if (status.files && Array.isArray(status.files)) {
|
|
64
|
+
status.files.forEach((file) => {
|
|
65
|
+
const indexStatus = file.index || ' ';
|
|
66
|
+
const workingDirStatus = file.working_dir || ' ';
|
|
67
|
+
const filePath = file.path;
|
|
68
|
+
|
|
69
|
+
// Renamed: check if file is in status.renamed array
|
|
70
|
+
if (status.renamed && status.renamed.some(r => r.to === filePath || r.from === filePath)) {
|
|
71
|
+
addFileIfUnique(filePath, 'renamed');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Deleted: index has 'D' or working_dir has 'D'
|
|
76
|
+
// Compound status can be "AD", "D", "MD", etc.
|
|
77
|
+
if (indexStatus.includes('D') || workingDirStatus.includes('D')) {
|
|
78
|
+
addFileIfUnique(filePath, 'deleted');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// New: both index and working_dir are '?'
|
|
83
|
+
if (indexStatus === '?' && workingDirStatus === '?') {
|
|
84
|
+
addFileIfUnique(filePath, 'new');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Staged: index has 'A' (not empty, not just '?')
|
|
89
|
+
// Compound status can be "A", "AM", "AD", etc.
|
|
90
|
+
if (indexStatus.includes('A') && indexStatus !== '?') {
|
|
91
|
+
addFileIfUnique(filePath, 'modified');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Unstaged modified: index is empty ' ' and working_dir is 'M'
|
|
96
|
+
// Or index has 'M' and working_dir is anything
|
|
97
|
+
if ((indexStatus === ' ' || indexStatus === 'M') && (workingDirStatus === 'M' || workingDirStatus.includes('M'))) {
|
|
98
|
+
addFileIfUnique(filePath, 'unstaged');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
// Fallback to the old format for backward compatibility
|
|
104
|
+
// Handle modified files
|
|
105
|
+
(status.modified || []).forEach((file: string) => {
|
|
106
|
+
addFileIfUnique(file, 'unstaged');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Handle staged files
|
|
110
|
+
(status.staged || []).forEach((file: string) => {
|
|
111
|
+
addFileIfUnique(file, 'modified');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Handle not_added (new) files
|
|
115
|
+
(status.not_added || []).forEach((file: string) => {
|
|
116
|
+
addFileIfUnique(file, 'new');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Handle deleted files
|
|
120
|
+
(status.deleted || []).forEach((file: string) => {
|
|
121
|
+
addFileIfUnique(file, 'deleted');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Handle renamed files
|
|
125
|
+
(status.renamed || []).forEach((rename: { from: string; to: string }) => {
|
|
126
|
+
addFileIfUnique(rename.to, 'renamed');
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Add mtime to all files
|
|
131
|
+
await Promise.all(fileList.map(async (f) => {
|
|
132
|
+
try {
|
|
133
|
+
const absPath = path.isAbsolute(f.path) ? f.path : path.join(root, f.path);
|
|
134
|
+
const stat = await fsPromises.stat(absPath);
|
|
135
|
+
f.mtime = stat.mtime;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// For deleted or inaccessible files, use epoch time
|
|
138
|
+
f.mtime = new Date(0);
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// Sort by mtime descending, then by filename
|
|
143
|
+
return fileList.sort((a, b) => {
|
|
144
|
+
const mtimeA = a.mtime || new Date(0);
|
|
145
|
+
const mtimeB = b.mtime || new Date(0);
|
|
146
|
+
const timeDiff = mtimeB.getTime() - mtimeA.getTime();
|
|
147
|
+
if (timeDiff !== 0) {
|
|
148
|
+
return timeDiff;
|
|
149
|
+
}
|
|
150
|
+
return a.path.localeCompare(b.path);
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// For this function, returning an empty array is better than crashing the app
|
|
154
|
+
// Git operations can fail for many reasons (not a git repo, etc.)
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function getFileContent(
|
|
160
|
+
filePath: string,
|
|
161
|
+
cwd: string = process.cwd(),
|
|
162
|
+
fsPromises: FsPromises = defaultFsPromises,
|
|
163
|
+
path: Path = defaultPath
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
try {
|
|
166
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
167
|
+
await fsPromises.access(absolutePath);
|
|
168
|
+
return await fsPromises.readFile(absolutePath, 'utf-8');
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error(`Failed to read file ${filePath}:`, error);
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function getCurrentBranch(cwd: string = process.cwd()): Promise<string> {
|
|
176
|
+
try {
|
|
177
|
+
const git = simpleGit(cwd);
|
|
178
|
+
const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
|
|
179
|
+
return branch;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// For this function, returning 'unknown' is acceptable behavior when not in a git repo
|
|
182
|
+
// So we return the default value instead of throwing
|
|
183
|
+
return 'unknown';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function getBranchCount(cwd: string = process.cwd()): Promise<number> {
|
|
188
|
+
try {
|
|
189
|
+
const git = simpleGit(cwd);
|
|
190
|
+
const branches = await git.branch();
|
|
191
|
+
return branches.all.filter(branch => !branch.startsWith('remotes/')).length;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// For this function, returning 0 is acceptable behavior when not in a git repo
|
|
194
|
+
// So we return the default value instead of throwing
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function getRepoRoot(cwd: string = process.cwd()): Promise<string> {
|
|
200
|
+
try {
|
|
201
|
+
const git = simpleGit(cwd);
|
|
202
|
+
const root = await git.revparse(['--show-toplevel']);
|
|
203
|
+
return root.trim();
|
|
204
|
+
} catch {
|
|
205
|
+
return cwd;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function revertFile(filePath: string, cwd: string = process.cwd()): Promise<void> {
|
|
210
|
+
const git = simpleGit(cwd);
|
|
211
|
+
await git.checkout(['HEAD', '--', filePath]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function isGitRepository(cwd: string = process.cwd()): Promise<boolean> {
|
|
215
|
+
try {
|
|
216
|
+
const git = simpleGit(cwd);
|
|
217
|
+
await git.revparse(['--git-dir']);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function deleteFile(
|
|
225
|
+
filePath: string,
|
|
226
|
+
cwd: string = process.cwd(),
|
|
227
|
+
fsPromises: FsPromises = defaultFsPromises,
|
|
228
|
+
path: Path = defaultPath
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
try {
|
|
231
|
+
const absolutePath = path.resolve(cwd, filePath);
|
|
232
|
+
await fsPromises.unlink(absolutePath);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// Silently handle the error as before
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function deleteFileSafely(
|
|
240
|
+
filePath: string,
|
|
241
|
+
cwd: string = process.cwd(),
|
|
242
|
+
fsPromises: FsPromises = defaultFsPromises,
|
|
243
|
+
path: Path = defaultPath
|
|
244
|
+
): Promise<boolean> {
|
|
245
|
+
const absolutePath = path.resolve(cwd, filePath);
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await fsPromises.access(absolutePath);
|
|
249
|
+
} catch {
|
|
250
|
+
return false; // File doesn't exist
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
// Use trash package for cross-platform recycle bin functionality
|
|
255
|
+
const { default: trash } = await import('trash');
|
|
256
|
+
await trash([absolutePath]);
|
|
257
|
+
|
|
258
|
+
// Wait a moment and verify file was actually deleted
|
|
259
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await fsPromises.access(absolutePath);
|
|
263
|
+
} catch {
|
|
264
|
+
return true; // Successfully deleted
|
|
265
|
+
}
|
|
266
|
+
return false; // Still exists (delete failed)
|
|
267
|
+
} catch (fallbackError) {
|
|
268
|
+
// Fallback to permanent delete if trash fails
|
|
269
|
+
try {
|
|
270
|
+
await fsPromises.unlink(absolutePath);
|
|
271
|
+
// Wait a moment and verify deletion
|
|
272
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await fsPromises.access(absolutePath);
|
|
276
|
+
} catch {
|
|
277
|
+
return true; // Successfully deleted
|
|
278
|
+
}
|
|
279
|
+
return false; // Still exists (delete failed)
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function searchFiles(searchTerm: string, filesToSearch: FileStatus[], cwd: string = process.cwd()): Promise<FileStatus[]> {
|
|
287
|
+
if (!searchTerm || !searchTerm.trim() || filesToSearch.length === 0) {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Sanitize and validate search term
|
|
292
|
+
const trimmedTerm = searchTerm.trim();
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
// Escape special characters that could be problematic in git grep
|
|
296
|
+
// Although -F flag should treat as fixed string, extra validation is good
|
|
297
|
+
const sanitizedTerm = trimmedTerm.replace(/[\\`$();|&<>{}[\]]/g, '');
|
|
298
|
+
|
|
299
|
+
if (!sanitizedTerm) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const git = simpleGit(cwd);
|
|
305
|
+
const root = await getRepoRoot(cwd);
|
|
306
|
+
|
|
307
|
+
// Use git grep to search ONLY within the specified files
|
|
308
|
+
const filePathArgs = filesToSearch.map(f => f.path);
|
|
309
|
+
const grepArgs = ['grep', '-i', '-l', '-F', '--', sanitizedTerm, '--', ...filePathArgs];
|
|
310
|
+
|
|
311
|
+
const result = await git.raw(grepArgs);
|
|
312
|
+
const matchedPaths = new Set(result.split('\n').map(p => p.trim()).filter(p => p !== ''));
|
|
313
|
+
|
|
314
|
+
if (matchedPaths.size === 0) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Filter input files to only include those that matched
|
|
319
|
+
return filesToSearch.filter(f => matchedPaths.has(f.path));
|
|
320
|
+
} catch (e: any) {
|
|
321
|
+
// git grep returns exit code 1 if no matches are found
|
|
322
|
+
// In any case, if search fails we return empty results
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function getRawDiff(filePath: string, cwd: string = process.cwd()): Promise<string> {
|
|
328
|
+
try {
|
|
329
|
+
const git = simpleGit(cwd);
|
|
330
|
+
// Use -U3 for 3 lines of context, matching our previous logic
|
|
331
|
+
const diff = await git.diff(['HEAD', '-U3', '--', filePath]);
|
|
332
|
+
return diff;
|
|
333
|
+
} catch (e) {
|
|
334
|
+
// For diff operations, returning empty string is acceptable when file is not in git
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface CommitInfo {
|
|
340
|
+
hash: string;
|
|
341
|
+
message: string;
|
|
342
|
+
author: string;
|
|
343
|
+
date: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function getCommitHistory(cwd: string = process.cwd(), limit?: number): Promise<CommitInfo[]> {
|
|
347
|
+
try {
|
|
348
|
+
const git = simpleGit(cwd);
|
|
349
|
+
const options = limit ? { maxCount: limit } : {};
|
|
350
|
+
const log = await git.log(options);
|
|
351
|
+
return log.all.map(commit => ({
|
|
352
|
+
hash: commit.hash.substring(0, 7),
|
|
353
|
+
message: commit.message,
|
|
354
|
+
author: commit.author_name,
|
|
355
|
+
date: commit.date
|
|
356
|
+
}));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
// Return empty array if commit history cannot be loaded
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ChangedFile {
|
|
364
|
+
path: string;
|
|
365
|
+
status: string; // Use the exact status code from Git
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function getFilesInCommit(commitHash: string, cwd: string = process.cwd()): Promise<ChangedFile[]> {
|
|
369
|
+
try {
|
|
370
|
+
const git = simpleGit(cwd);
|
|
371
|
+
// Get the list of files changed in the specific commit using show command
|
|
372
|
+
const result = await git.show(['--name-status', '--format=', commitHash]);
|
|
373
|
+
|
|
374
|
+
const files: ChangedFile[] = [];
|
|
375
|
+
|
|
376
|
+
// Parse the output - each line has format like "A path/to/file" or "M path/to/file"
|
|
377
|
+
const lines = result.split('\n').filter(line => line.trim() !== '');
|
|
378
|
+
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
const trimmedLine = line.trim();
|
|
381
|
+
if (trimmedLine) {
|
|
382
|
+
const parts = trimmedLine.split(/\s+/);
|
|
383
|
+
if (parts.length >= 2) {
|
|
384
|
+
const statusCode = parts[0];
|
|
385
|
+
const filePath = parts.slice(1).join(' ');
|
|
386
|
+
|
|
387
|
+
if (statusCode && filePath) { // Only add if both statusCode and filePath exist and are not empty
|
|
388
|
+
files.push({
|
|
389
|
+
path: filePath,
|
|
390
|
+
status: statusCode // Use the exact status code from Git
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return files;
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error(`Failed to get files for commit ${commitHash}:`, error);
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BUILD_VERSION = "2.0.5";
|
package/dist/diffwatch.exe
DELETED
|
Binary file
|