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.
@@ -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";
Binary file