diffstalker 0.2.0 → 0.2.1

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.
@@ -1,5 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import * as fs from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
3
4
  import { watch } from 'chokidar';
4
5
  import { EventEmitter } from 'node:events';
5
6
  import ignore from 'ignore';
@@ -16,12 +17,12 @@ export class GitStateManager extends EventEmitter {
16
17
  queue;
17
18
  gitWatcher = null;
18
19
  workingDirWatcher = null;
19
- ignorer = null;
20
+ ignorers = new Map();
21
+ diffDebounceTimer = null;
20
22
  // Current state
21
23
  _state = {
22
24
  status: null,
23
25
  diff: null,
24
- stagedDiff: '',
25
26
  selectedFile: null,
26
27
  isLoading: false,
27
28
  error: null,
@@ -77,24 +78,49 @@ export class GitStateManager extends EventEmitter {
77
78
  this.emit('compare-selection-change', this._compareSelectionState);
78
79
  }
79
80
  /**
80
- * Load gitignore patterns from .gitignore and .git/info/exclude.
81
- * Returns an Ignore instance that can test paths.
81
+ * Load gitignore patterns from all .gitignore files and .git/info/exclude.
82
+ * Returns a Map of directory → Ignore instance, where each instance handles
83
+ * patterns relative to its own directory (matching how git scopes .gitignore files).
82
84
  */
83
- loadGitignore() {
84
- const ig = ignore();
85
- // Always ignore .git directory (has its own dedicated watcher)
86
- ig.add('.git');
87
- // Load .gitignore if it exists
88
- const gitignorePath = path.join(this.repoPath, '.gitignore');
89
- if (fs.existsSync(gitignorePath)) {
90
- ig.add(fs.readFileSync(gitignorePath, 'utf-8'));
85
+ loadGitignores() {
86
+ const ignorers = new Map();
87
+ // Root ignorer: .git dir + root .gitignore + .git/info/exclude
88
+ const rootIg = ignore();
89
+ rootIg.add('.git');
90
+ const rootGitignorePath = path.join(this.repoPath, '.gitignore');
91
+ if (fs.existsSync(rootGitignorePath)) {
92
+ rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
91
93
  }
92
- // Load .git/info/exclude if it exists (repo-specific ignores)
93
94
  const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
94
95
  if (fs.existsSync(excludePath)) {
95
- ig.add(fs.readFileSync(excludePath, 'utf-8'));
96
+ rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
97
+ }
98
+ ignorers.set('', rootIg);
99
+ // Find all nested .gitignore files using git ls-files
100
+ try {
101
+ const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
102
+ for (const entry of output.split('\0')) {
103
+ if (!entry || entry === '.gitignore')
104
+ continue;
105
+ if (!entry.endsWith('.gitignore'))
106
+ continue;
107
+ const dir = path.dirname(entry);
108
+ const absPath = path.join(this.repoPath, entry);
109
+ try {
110
+ const content = fs.readFileSync(absPath, 'utf-8');
111
+ const ig = ignore();
112
+ ig.add(content);
113
+ ignorers.set(dir, ig);
114
+ }
115
+ catch {
116
+ // Skip unreadable files
117
+ }
118
+ }
96
119
  }
97
- return ig;
120
+ catch {
121
+ // git ls-files failed — we still have the root ignorer
122
+ }
123
+ return ignorers;
98
124
  }
99
125
  /**
100
126
  * Start watching for file changes.
@@ -117,19 +143,26 @@ export class GitStateManager extends EventEmitter {
117
143
  interval: 100,
118
144
  });
119
145
  // --- Working directory watcher with gitignore support ---
120
- this.ignorer = this.loadGitignore();
146
+ this.ignorers = this.loadGitignores();
121
147
  this.workingDirWatcher = watch(this.repoPath, {
122
148
  persistent: true,
123
149
  ignoreInitial: true,
124
150
  ignored: (filePath) => {
125
- // Get path relative to repo root
126
151
  const relativePath = path.relative(this.repoPath, filePath);
127
- // Don't ignore the repo root itself
128
152
  if (!relativePath)
129
153
  return false;
130
- // Check against gitignore patterns
131
- // When this returns true for a directory, chokidar won't recurse into it
132
- return this.ignorer?.ignores(relativePath) ?? false;
154
+ // Walk ancestor directories from root to parent, checking each ignorer
155
+ const parts = relativePath.split('/');
156
+ for (let depth = 0; depth < parts.length; depth++) {
157
+ const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
158
+ const ig = this.ignorers.get(dir);
159
+ if (ig) {
160
+ const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
161
+ if (ig.ignores(relToDir))
162
+ return true;
163
+ }
164
+ }
165
+ return false;
133
166
  },
134
167
  awaitWriteFinish: {
135
168
  stabilityThreshold: 100,
@@ -140,7 +173,7 @@ export class GitStateManager extends EventEmitter {
140
173
  this.gitWatcher.on('change', (filePath) => {
141
174
  // Reload gitignore patterns if .gitignore changed
142
175
  if (filePath === gitignorePath) {
143
- this.ignorer = this.loadGitignore();
176
+ this.ignorers = this.loadGitignores();
144
177
  }
145
178
  scheduleRefresh();
146
179
  });
@@ -162,15 +195,47 @@ export class GitStateManager extends EventEmitter {
162
195
  * Stop watching and clean up resources.
163
196
  */
164
197
  dispose() {
198
+ if (this.diffDebounceTimer)
199
+ clearTimeout(this.diffDebounceTimer);
165
200
  this.gitWatcher?.close();
166
201
  this.workingDirWatcher?.close();
167
202
  removeQueueForRepo(this.repoPath);
168
203
  }
169
204
  /**
170
205
  * Schedule a refresh (coalesced if one is already pending).
206
+ * Also refreshes history and compare data if they were previously loaded.
171
207
  */
172
208
  scheduleRefresh() {
173
- this.queue.scheduleRefresh(() => this.doRefresh());
209
+ this.queue.scheduleRefresh(async () => {
210
+ await this.doRefresh();
211
+ // Also refresh history if it was loaded (has commits)
212
+ if (this._historyState.commits.length > 0) {
213
+ await this.doLoadHistory();
214
+ }
215
+ // Also refresh compare if it was loaded (has a base branch set)
216
+ if (this._compareState.compareBaseBranch) {
217
+ await this.doRefreshCompareDiff(false);
218
+ }
219
+ });
220
+ }
221
+ /**
222
+ * Schedule a lightweight status-only refresh (no diff fetching).
223
+ * Used after stage/unstage where the diff view updates on file selection.
224
+ */
225
+ scheduleStatusRefresh() {
226
+ this.queue.scheduleRefresh(async () => {
227
+ const newStatus = await getStatus(this.repoPath);
228
+ if (!newStatus.isRepo) {
229
+ this.updateState({
230
+ status: newStatus,
231
+ diff: null,
232
+ isLoading: false,
233
+ error: 'Not a git repository',
234
+ });
235
+ return;
236
+ }
237
+ this.updateState({ status: newStatus, isLoading: false });
238
+ });
174
239
  }
175
240
  /**
176
241
  * Immediately refresh git state.
@@ -186,17 +251,15 @@ export class GitStateManager extends EventEmitter {
186
251
  this.updateState({
187
252
  status: newStatus,
188
253
  diff: null,
189
- stagedDiff: '',
190
254
  isLoading: false,
191
255
  error: 'Not a git repository',
192
256
  });
193
257
  return;
194
258
  }
195
- // Fetch all diffs atomically
196
- const [allStagedDiff, allUnstagedDiff] = await Promise.all([
197
- getStagedDiff(this.repoPath),
198
- getDiff(this.repoPath, undefined, false),
199
- ]);
259
+ // Emit status immediately so the file list updates after a single git spawn
260
+ this.updateState({ status: newStatus });
261
+ // Fetch unstaged diff (updates diff view once complete)
262
+ const allUnstagedDiff = await getDiff(this.repoPath, undefined, false);
200
263
  // Determine display diff based on selected file
201
264
  let displayDiff;
202
265
  const currentSelectedFile = this._state.selectedFile;
@@ -211,26 +274,16 @@ export class GitStateManager extends EventEmitter {
211
274
  }
212
275
  }
213
276
  else {
214
- // File no longer exists - clear selection
215
- displayDiff = allUnstagedDiff.raw ? allUnstagedDiff : allStagedDiff;
277
+ // File no longer exists - clear selection, show unstaged diff
278
+ displayDiff = allUnstagedDiff;
216
279
  this.updateState({ selectedFile: null });
217
280
  }
218
281
  }
219
282
  else {
220
- if (allUnstagedDiff.raw) {
221
- displayDiff = allUnstagedDiff;
222
- }
223
- else if (allStagedDiff.raw) {
224
- displayDiff = allStagedDiff;
225
- }
226
- else {
227
- displayDiff = { raw: '', lines: [] };
228
- }
283
+ displayDiff = allUnstagedDiff;
229
284
  }
230
285
  this.updateState({
231
- status: newStatus,
232
286
  diff: displayDiff,
233
- stagedDiff: allStagedDiff.raw,
234
287
  isLoading: false,
235
288
  });
236
289
  }
@@ -243,12 +296,36 @@ export class GitStateManager extends EventEmitter {
243
296
  }
244
297
  /**
245
298
  * Select a file and update the diff display.
299
+ * The selection highlight updates immediately; the diff fetch is debounced
300
+ * so rapid arrow-key presses only spawn one git process for the final file.
246
301
  */
247
- async selectFile(file) {
302
+ selectFile(file) {
248
303
  this.updateState({ selectedFile: file });
249
304
  if (!this._state.status?.isRepo)
250
305
  return;
251
- await this.queue.enqueue(async () => {
306
+ if (this.diffDebounceTimer) {
307
+ // Already cooling down — reset the timer and fetch when it expires
308
+ clearTimeout(this.diffDebounceTimer);
309
+ this.diffDebounceTimer = setTimeout(() => {
310
+ this.diffDebounceTimer = null;
311
+ this.fetchDiffForSelection();
312
+ }, 20);
313
+ }
314
+ else {
315
+ // First call — fetch immediately, then start cooldown
316
+ this.fetchDiffForSelection();
317
+ this.diffDebounceTimer = setTimeout(() => {
318
+ this.diffDebounceTimer = null;
319
+ }, 20);
320
+ }
321
+ }
322
+ fetchDiffForSelection() {
323
+ const file = this._state.selectedFile;
324
+ this.queue
325
+ .enqueue(async () => {
326
+ // Selection changed while queued — skip stale fetch
327
+ if (file !== this._state.selectedFile)
328
+ return;
252
329
  if (file) {
253
330
  let fileDiff;
254
331
  if (file.status === 'untracked') {
@@ -257,31 +334,30 @@ export class GitStateManager extends EventEmitter {
257
334
  else {
258
335
  fileDiff = await getDiff(this.repoPath, file.path, file.staged);
259
336
  }
260
- this.updateState({ diff: fileDiff });
337
+ if (file === this._state.selectedFile) {
338
+ this.updateState({ diff: fileDiff });
339
+ }
261
340
  }
262
341
  else {
263
342
  const allDiff = await getStagedDiff(this.repoPath);
264
- this.updateState({ diff: allDiff });
343
+ if (this._state.selectedFile === null) {
344
+ this.updateState({ diff: allDiff });
345
+ }
265
346
  }
347
+ })
348
+ .catch((err) => {
349
+ this.updateState({
350
+ error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
351
+ });
266
352
  });
267
353
  }
268
354
  /**
269
- * Stage a file with optimistic update.
355
+ * Stage a file.
270
356
  */
271
357
  async stage(file) {
272
- // Optimistic update
273
- const currentStatus = this._state.status;
274
- if (currentStatus) {
275
- this.updateState({
276
- status: {
277
- ...currentStatus,
278
- files: currentStatus.files.map((f) => f.path === file.path && !f.staged ? { ...f, staged: true } : f),
279
- },
280
- });
281
- }
282
358
  try {
283
359
  await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
284
- this.scheduleRefresh();
360
+ this.scheduleStatusRefresh();
285
361
  }
286
362
  catch (err) {
287
363
  await this.refresh();
@@ -291,22 +367,12 @@ export class GitStateManager extends EventEmitter {
291
367
  }
292
368
  }
293
369
  /**
294
- * Unstage a file with optimistic update.
370
+ * Unstage a file.
295
371
  */
296
372
  async unstage(file) {
297
- // Optimistic update
298
- const currentStatus = this._state.status;
299
- if (currentStatus) {
300
- this.updateState({
301
- status: {
302
- ...currentStatus,
303
- files: currentStatus.files.map((f) => f.path === file.path && f.staged ? { ...f, staged: false } : f),
304
- },
305
- });
306
- }
307
373
  try {
308
374
  await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
309
- this.scheduleRefresh();
375
+ this.scheduleStatusRefresh();
310
376
  }
311
377
  catch (err) {
312
378
  await this.refresh();
@@ -385,27 +451,7 @@ export class GitStateManager extends EventEmitter {
385
451
  async refreshCompareDiff(includeUncommitted = false) {
386
452
  this.updateCompareState({ compareLoading: true, compareError: null });
387
453
  try {
388
- await this.queue.enqueue(async () => {
389
- let base = this._compareState.compareBaseBranch;
390
- if (!base) {
391
- // Try cached value first, then fall back to default detection
392
- base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
393
- this.updateCompareState({ compareBaseBranch: base });
394
- }
395
- if (base) {
396
- const diff = includeUncommitted
397
- ? await getCompareDiffWithUncommitted(this.repoPath, base)
398
- : await getDiffBetweenRefs(this.repoPath, base);
399
- this.updateCompareState({ compareDiff: diff, compareLoading: false });
400
- }
401
- else {
402
- this.updateCompareState({
403
- compareDiff: null,
404
- compareLoading: false,
405
- compareError: 'No base branch found',
406
- });
407
- }
408
- });
454
+ await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
409
455
  }
410
456
  catch (err) {
411
457
  this.updateCompareState({
@@ -414,6 +460,30 @@ export class GitStateManager extends EventEmitter {
414
460
  });
415
461
  }
416
462
  }
463
+ /**
464
+ * Internal: refresh compare diff (called within queue).
465
+ */
466
+ async doRefreshCompareDiff(includeUncommitted) {
467
+ let base = this._compareState.compareBaseBranch;
468
+ if (!base) {
469
+ // Try cached value first, then fall back to default detection
470
+ base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
471
+ this.updateCompareState({ compareBaseBranch: base });
472
+ }
473
+ if (base) {
474
+ const diff = includeUncommitted
475
+ ? await getCompareDiffWithUncommitted(this.repoPath, base)
476
+ : await getDiffBetweenRefs(this.repoPath, base);
477
+ this.updateCompareState({ compareDiff: diff, compareLoading: false });
478
+ }
479
+ else {
480
+ this.updateCompareState({
481
+ compareDiff: null,
482
+ compareLoading: false,
483
+ compareError: 'No base branch found',
484
+ });
485
+ }
486
+ }
417
487
  /**
418
488
  * Get candidate base branches for branch comparison.
419
489
  */
@@ -435,8 +505,7 @@ export class GitStateManager extends EventEmitter {
435
505
  async loadHistory(count = 100) {
436
506
  this.updateHistoryState({ isLoading: true });
437
507
  try {
438
- const commits = await this.queue.enqueue(() => getCommitHistory(this.repoPath, count));
439
- this.updateHistoryState({ commits, isLoading: false });
508
+ await this.queue.enqueue(() => this.doLoadHistory(count));
440
509
  }
441
510
  catch (err) {
442
511
  this.updateHistoryState({ isLoading: false });
@@ -445,6 +514,13 @@ export class GitStateManager extends EventEmitter {
445
514
  });
446
515
  }
447
516
  }
517
+ /**
518
+ * Internal: load commit history (called within queue).
519
+ */
520
+ async doLoadHistory(count = 100) {
521
+ const commits = await getCommitHistory(this.repoPath, count);
522
+ this.updateHistoryState({ commits, isLoading: false });
523
+ }
448
524
  /**
449
525
  * Select a commit in history view and load its diff.
450
526
  */
package/dist/git/diff.js CHANGED
@@ -302,6 +302,8 @@ export async function getDiffBetweenRefs(repoPath, baseRef) {
302
302
  date: new Date(entry.date),
303
303
  refs: entry.refs || '',
304
304
  }));
305
+ // Sort files alphabetically by path
306
+ fileDiffs.sort((a, b) => a.path.localeCompare(b.path));
305
307
  return {
306
308
  baseBranch: baseRef,
307
309
  stats: {
@@ -457,6 +459,8 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
457
459
  totalAdditions += file.additions;
458
460
  totalDeletions += file.deletions;
459
461
  }
462
+ // Sort files alphabetically by path
463
+ mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
460
464
  return {
461
465
  baseBranch: committedDiff.baseBranch,
462
466
  stats: {