diffstalker 0.2.1 → 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/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 {
@@ -338,16 +433,13 @@ export async function getCommitDiff(repoPath, hash) {
338
433
  */
339
434
  export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
340
435
  const git = simpleGit(repoPath);
341
- // Get the committed PR diff first
342
436
  const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
343
- // Get uncommitted changes (both staged and unstaged)
344
437
  const stagedRaw = await git.diff(['--cached', '--numstat']);
345
438
  const unstagedRaw = await git.diff(['--numstat']);
346
439
  const stagedDiff = await git.diff(['--cached']);
347
440
  const unstagedDiff = await git.diff([]);
348
- // Parse uncommitted file stats
441
+ // Parse uncommitted file stats from numstat output
349
442
  const uncommittedFiles = new Map();
350
- // Parse staged files
351
443
  for (const line of stagedRaw
352
444
  .trim()
353
445
  .split('\n')
@@ -360,7 +452,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
360
452
  uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
361
453
  }
362
454
  }
363
- // Parse unstaged files
364
455
  for (const line of unstagedRaw
365
456
  .trim()
366
457
  .split('\n')
@@ -381,7 +472,7 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
381
472
  }
382
473
  }
383
474
  }
384
- // Get status for file status detection
475
+ // Build status map from git status
385
476
  const status = await git.status();
386
477
  const statusMap = new Map();
387
478
  for (const file of status.files) {
@@ -402,7 +493,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
402
493
  const uncommittedFileDiffs = [];
403
494
  const combinedDiff = stagedDiff + unstagedDiff;
404
495
  const diffChunks = combinedDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
405
- // Track files we've already processed (avoid duplicates if file has both staged and unstaged)
406
496
  const processedFiles = new Set();
407
497
  for (const chunk of diffChunks) {
408
498
  const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
@@ -427,13 +517,9 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
427
517
  // Merge: keep committed files, add/replace with uncommitted
428
518
  const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
429
519
  const mergedFiles = [];
430
- // Add committed files first
431
520
  for (const file of committedDiff.files) {
432
521
  const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
433
522
  if (uncommittedFile) {
434
- // If file has both committed and uncommitted changes, combine them
435
- // For simplicity, we'll show committed + uncommitted as separate entries
436
- // with the uncommitted one marked
437
523
  mergedFiles.push(file);
438
524
  mergedFiles.push(uncommittedFile);
439
525
  }
@@ -441,25 +527,20 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
441
527
  mergedFiles.push(file);
442
528
  }
443
529
  }
444
- // Add uncommitted-only files (not in committed diff)
445
530
  for (const file of uncommittedFileDiffs) {
446
531
  if (!committedFilePaths.has(file.path)) {
447
532
  mergedFiles.push(file);
448
533
  }
449
534
  }
450
- // Calculate new totals including uncommitted
535
+ // Calculate totals
451
536
  let totalAdditions = 0;
452
537
  let totalDeletions = 0;
453
538
  const seenPaths = new Set();
454
539
  for (const file of mergedFiles) {
455
- // Count unique file paths for stats
456
- if (!seenPaths.has(file.path)) {
457
- seenPaths.add(file.path);
458
- }
540
+ seenPaths.add(file.path);
459
541
  totalAdditions += file.additions;
460
542
  totalDeletions += file.deletions;
461
543
  }
462
- // Sort files alphabetically by path
463
544
  mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
464
545
  return {
465
546
  baseBranch: committedDiff.baseBranch,
@@ -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
- const files = [];
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
+ }