codeant-cli 0.1.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.
@@ -0,0 +1,762 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { promisify } from 'util';
4
+ import { exec } from 'child_process';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ class GitDiffHelper {
9
+ constructor(workspacePath) {
10
+ this.workspacePath = workspacePath;
11
+ this.gitRoot = null;
12
+ this.currentBranch = null;
13
+ this.defaultBranch = null;
14
+ this.baseCommit = null;
15
+ }
16
+
17
+ /**
18
+ * Find the git root directory by traversing up from the workspace path
19
+ */
20
+ async findGitRoot(directory) {
21
+ try {
22
+ // First check current directory
23
+ const files = await fs.readdir(directory);
24
+ if (files.includes('.git')) {
25
+ return directory;
26
+ }
27
+
28
+ // Traverse up to parent directories
29
+ let currentDir = directory;
30
+ while (currentDir !== path.dirname(currentDir)) {
31
+ currentDir = path.dirname(currentDir);
32
+ try {
33
+ const parentFiles = await fs.readdir(currentDir);
34
+ if (parentFiles.includes('.git')) {
35
+ return currentDir;
36
+ }
37
+ } catch (err) {
38
+ break;
39
+ }
40
+ }
41
+
42
+ return null;
43
+ } catch (error) {
44
+ console.error(`Error finding git root: ${error.message}`);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Initialize the helper: finds git root, current branch, default branch, and base commit
51
+ */
52
+ async init() {
53
+ // Find git root
54
+ this.gitRoot = await this.findGitRoot(this.workspacePath);
55
+ if (!this.gitRoot) {
56
+ throw new Error('Could not find a .git directory.');
57
+ }
58
+
59
+ try {
60
+ // Try to fetch origin (silently fails if no remote)
61
+ await this.fetchOrigin();
62
+ } catch (err) {
63
+ // No remote, continue anyway
64
+ }
65
+
66
+ try {
67
+ // Get current branch
68
+ const { stdout: branchName } = await execAsync(
69
+ 'git rev-parse --abbrev-ref HEAD',
70
+ { cwd: this.gitRoot }
71
+ );
72
+ this.currentBranch = branchName.trim();
73
+ } catch (err) {
74
+ this.currentBranch = 'main';
75
+ }
76
+
77
+ // Determine default branch
78
+ try {
79
+ const { stdout: remoteBranch } = await execAsync(
80
+ 'git rev-parse --abbrev-ref origin/HEAD',
81
+ { cwd: this.gitRoot }
82
+ );
83
+ this.defaultBranch = remoteBranch.trim().replace('origin/', '');
84
+ } catch (err) {
85
+ this.defaultBranch = 'main';
86
+ }
87
+
88
+ // Find merge base commit or fallback to HEAD
89
+ try {
90
+ const { stdout: mergeBase } = await execAsync(
91
+ `git merge-base ${this.currentBranch} origin/${this.defaultBranch}`,
92
+ { cwd: this.gitRoot }
93
+ );
94
+ this.baseCommit = mergeBase.trim();
95
+ } catch (err) {
96
+ // Fallback to HEAD if no merge base found
97
+ try {
98
+ const { stdout: head } = await execAsync(
99
+ 'git rev-parse HEAD',
100
+ { cwd: this.gitRoot }
101
+ );
102
+ this.baseCommit = head.trim();
103
+ } catch (headErr) {
104
+ // No commits yet - set to empty tree
105
+ this.baseCommit = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
106
+ }
107
+ }
108
+ }
109
+
110
+ async fetchOrigin() {
111
+ try {
112
+ await execAsync('git fetch origin', { cwd: this.gitRoot });
113
+ } catch (err) {
114
+ // Silently continue if fetch fails (e.g., offline)
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get all branches (local and remote)
120
+ */
121
+ async getAllBranches() {
122
+ if (!this.gitRoot) {
123
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
124
+ }
125
+
126
+ const { stdout: branchesStr } = await execAsync(
127
+ 'git branch -a --format="%(refname:short)"',
128
+ { cwd: this.gitRoot }
129
+ );
130
+
131
+ const branches = branchesStr
132
+ .split('\n')
133
+ .map(b => b.trim())
134
+ .filter(Boolean)
135
+ .map(b => b.replace('origin/', ''))
136
+ .filter((value, index, self) => self.indexOf(value) === index);
137
+
138
+ return branches;
139
+ }
140
+
141
+ /**
142
+ * Get recent commits with metadata
143
+ */
144
+ async getRecentCommits(limit = 10) {
145
+ if (!this.gitRoot) {
146
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
147
+ }
148
+
149
+ const { stdout: commitsStr } = await execAsync(
150
+ `git log --pretty=format:"%H|%s|%an|%ar" -n ${limit}`,
151
+ { cwd: this.gitRoot }
152
+ );
153
+
154
+ const commits = commitsStr
155
+ .split('\n')
156
+ .filter(Boolean)
157
+ .map(line => {
158
+ const [hash, message, author, date] = line.split('|');
159
+ return { hash, message, author, date };
160
+ });
161
+
162
+ return commits;
163
+ }
164
+
165
+ /**
166
+ * Get files changed since merge base
167
+ */
168
+ async getChangedFiles() {
169
+ if (!this.gitRoot || !this.baseCommit) {
170
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
171
+ }
172
+
173
+ const cmd = [
174
+ `git diff --name-only --diff-filter=ACMRD ${this.baseCommit}`,
175
+ `git diff --name-only --cached --diff-filter=ACMRD`,
176
+ `git ls-files --others --exclude-standard`
177
+ ].join(' && ');
178
+
179
+ const { stdout: changedFiles } = await execAsync(cmd, { cwd: this.gitRoot });
180
+
181
+ const uniqueFiles = Array.from(new Set(
182
+ changedFiles
183
+ .split('\n')
184
+ .map(f => f.trim())
185
+ .filter(Boolean)
186
+ ));
187
+
188
+ return uniqueFiles;
189
+ }
190
+
191
+ /**
192
+ * Get staged files only
193
+ */
194
+ async getStagedFiles() {
195
+ if (!this.gitRoot) {
196
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
197
+ }
198
+
199
+ const { stdout: stagedFiles } = await execAsync(
200
+ 'git diff --name-only --cached',
201
+ { cwd: this.gitRoot }
202
+ );
203
+
204
+ return stagedFiles
205
+ .split('\n')
206
+ .map(f => f.trim())
207
+ .filter(Boolean);
208
+ }
209
+
210
+ /**
211
+ * Get diff based on review configuration
212
+ */
213
+ async getDiffBasedOnReviewConfig(reviewConfig = null) {
214
+ if (!this.gitRoot) {
215
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
216
+ }
217
+
218
+ const config = reviewConfig || {
219
+ type: 'branch-diff',
220
+ targetBranch: this.defaultBranch,
221
+ commits: null
222
+ };
223
+
224
+ switch (config.type) {
225
+ case 'branch-diff':
226
+ return this.getAllDiffInfo();
227
+
228
+ case 'last-commit':
229
+ return this._getLastCommitDiff();
230
+
231
+ case 'select-commits':
232
+ if (!config.commits || config.commits.length === 0) {
233
+ return [];
234
+ }
235
+ return this._getSpecificCommitsDiff(config.commits);
236
+
237
+ case 'uncommitted':
238
+ return this._getUncommittedChanges();
239
+
240
+ case 'staged-only':
241
+ return this._getStagedChanges();
242
+
243
+ case 'unpushed':
244
+ return this.getUnpushedChangesDiff();
245
+
246
+ default:
247
+ return this.getAllDiffInfo();
248
+ }
249
+ }
250
+
251
+ async _getLastCommitDiff() {
252
+ if (!this.gitRoot) {
253
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
254
+ }
255
+
256
+ // First check if there are any commits
257
+ try {
258
+ await execAsync('git rev-parse HEAD', { cwd: this.gitRoot });
259
+ } catch (err) {
260
+ // No commits at all
261
+ return [];
262
+ }
263
+
264
+ try {
265
+ // Check if HEAD~1 exists (more than one commit)
266
+ await execAsync('git rev-parse HEAD~1', { cwd: this.gitRoot });
267
+
268
+ const { stdout: changedFiles } = await execAsync(
269
+ 'git diff --name-only HEAD~1 HEAD',
270
+ { cwd: this.gitRoot }
271
+ );
272
+
273
+ const files = changedFiles
274
+ .split('\n')
275
+ .map(f => f.trim())
276
+ .filter(Boolean);
277
+
278
+ if (files.length === 0) {
279
+ return [];
280
+ }
281
+
282
+ const originalBaseCommit = this.baseCommit;
283
+ this.baseCommit = 'HEAD~1';
284
+
285
+ const diffs = await Promise.all(
286
+ files.map(fp => this.getDiffInfoForFile(fp))
287
+ );
288
+
289
+ this.baseCommit = originalBaseCommit;
290
+ return diffs.flat();
291
+ } catch (err) {
292
+ // HEAD~1 doesn't exist - this is the first commit
293
+ try {
294
+ const { stdout: changedFiles } = await execAsync(
295
+ 'git diff-tree --no-commit-id --name-only -r HEAD',
296
+ { cwd: this.gitRoot }
297
+ );
298
+
299
+ const files = changedFiles
300
+ .split('\n')
301
+ .map(f => f.trim())
302
+ .filter(Boolean);
303
+
304
+ if (files.length === 0) {
305
+ return [];
306
+ }
307
+
308
+ // For first commit, compare against empty tree
309
+ const originalBaseCommit = this.baseCommit;
310
+ this.baseCommit = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
311
+
312
+ const diffs = await Promise.all(
313
+ files.map(fp => this.getDiffInfoForFile(fp))
314
+ );
315
+
316
+ this.baseCommit = originalBaseCommit;
317
+ return diffs.flat();
318
+ } catch (innerErr) {
319
+ console.error('Error getting last commit diff:', innerErr.message);
320
+ return [];
321
+ }
322
+ }
323
+ }
324
+
325
+ async _getSpecificCommitsDiff(commitHashes) {
326
+ const allDiffs = [];
327
+ const originalBaseCommit = this.baseCommit;
328
+
329
+ for (const hash of commitHashes) {
330
+ try {
331
+ const { stdout: changedFiles } = await execAsync(
332
+ `git diff --name-only ${hash}~1 ${hash}`,
333
+ { cwd: this.gitRoot }
334
+ );
335
+
336
+ const files = changedFiles
337
+ .split('\n')
338
+ .map(f => f.trim())
339
+ .filter(Boolean);
340
+
341
+ this.baseCommit = `${hash}~1`;
342
+ const commitDiffs = await Promise.all(
343
+ files.map(fp => this.getDiffInfoForFile(fp))
344
+ );
345
+
346
+ allDiffs.push(...commitDiffs.flat());
347
+ } catch (error) {
348
+ console.error(`Error processing commit ${hash}:`, error.message);
349
+ }
350
+ }
351
+
352
+ this.baseCommit = originalBaseCommit;
353
+ return allDiffs;
354
+ }
355
+
356
+ async _getUncommittedChanges() {
357
+ const { stdout: allModified } = await execAsync(
358
+ 'git status --porcelain',
359
+ { cwd: this.gitRoot }
360
+ );
361
+
362
+ const files = allModified
363
+ .split('\n')
364
+ .map(line => line.substring(3).trim())
365
+ .filter(Boolean);
366
+
367
+ const diffs = [];
368
+ for (const file of files) {
369
+ try {
370
+ const { stdout: patchStr } = await execAsync(
371
+ `git diff HEAD -- "${file}"`,
372
+ { cwd: this.gitRoot }
373
+ );
374
+
375
+ if (patchStr) {
376
+ const diffInfo = await this.getDiffInfoForFile(file);
377
+ diffs.push(...diffInfo);
378
+ }
379
+ } catch (error) {
380
+ // Skip files that can't be diffed
381
+ }
382
+ }
383
+
384
+ return diffs;
385
+ }
386
+
387
+ async _getStagedChanges() {
388
+ const { stdout: stagedFiles } = await execAsync(
389
+ 'git diff --name-only --cached HEAD',
390
+ { cwd: this.gitRoot }
391
+ );
392
+
393
+ const files = stagedFiles
394
+ .split('\n')
395
+ .map(f => f.trim())
396
+ .filter(Boolean);
397
+
398
+ const diffs = [];
399
+ const originalBaseCommit = this.baseCommit;
400
+
401
+ for (const file of files) {
402
+ try {
403
+ this.baseCommit = 'HEAD';
404
+ const diffInfo = await this.getDiffInfoForFile(file);
405
+ diffs.push(...diffInfo);
406
+ } catch (error) {
407
+ console.error(`Error processing staged file ${file}:`, error.message);
408
+ } finally {
409
+ this.baseCommit = originalBaseCommit;
410
+ }
411
+ }
412
+
413
+ return diffs;
414
+ }
415
+
416
+ async getUnpushedChangesDiff() {
417
+ if (!this.gitRoot) {
418
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
419
+ }
420
+
421
+ try {
422
+ const { stdout: currentBranchRaw } = await execAsync(
423
+ 'git rev-parse --abbrev-ref HEAD',
424
+ { cwd: this.gitRoot }
425
+ );
426
+ const currentBranch = currentBranchRaw.trim();
427
+
428
+ let upstream = null;
429
+
430
+ try {
431
+ const { stdout: upstreamRaw } = await execAsync(
432
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
433
+ { cwd: this.gitRoot }
434
+ );
435
+ upstream = upstreamRaw.trim();
436
+ } catch (upstreamError) {
437
+ // Try to find remote branch
438
+ try {
439
+ const { stdout: remoteBranches } = await execAsync(
440
+ 'git branch -r',
441
+ { cwd: this.gitRoot }
442
+ );
443
+
444
+ if (remoteBranches.includes(`origin/${currentBranch}`)) {
445
+ upstream = `origin/${currentBranch}`;
446
+ }
447
+ } catch (error) {
448
+ // Ignore
449
+ }
450
+
451
+ if (!upstream) {
452
+ const defaultBranches = ['origin/main', 'origin/master'];
453
+ for (const branch of defaultBranches) {
454
+ try {
455
+ await execAsync(`git rev-parse ${branch}`, { cwd: this.gitRoot });
456
+ upstream = branch;
457
+ break;
458
+ } catch (e) {
459
+ // Try next
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ if (!upstream) {
466
+ return this._getUncommittedChanges();
467
+ }
468
+
469
+ // Get unpushed files
470
+ const { stdout: unpushedFiles } = await execAsync(
471
+ `git diff --name-only ${upstream}..HEAD`,
472
+ { cwd: this.gitRoot }
473
+ );
474
+ const unpushedFilesList = unpushedFiles.split('\n').map(f => f.trim()).filter(Boolean);
475
+
476
+ // Get staged files
477
+ const { stdout: stagedFiles } = await execAsync(
478
+ 'git diff --name-only --cached',
479
+ { cwd: this.gitRoot }
480
+ );
481
+ const stagedFilesList = stagedFiles.split('\n').map(f => f.trim()).filter(Boolean);
482
+
483
+ // Get unstaged files
484
+ const { stdout: unstagedFiles } = await execAsync(
485
+ 'git diff --name-only',
486
+ { cwd: this.gitRoot }
487
+ );
488
+ const unstagedFilesList = unstagedFiles.split('\n').map(f => f.trim()).filter(Boolean);
489
+
490
+ // Combine and deduplicate
491
+ const uniqueFiles = Array.from(new Set([
492
+ ...unpushedFilesList,
493
+ ...stagedFilesList,
494
+ ...unstagedFilesList
495
+ ]));
496
+
497
+ if (uniqueFiles.length === 0) {
498
+ return [];
499
+ }
500
+
501
+ const originalBaseCommit = this.baseCommit;
502
+ try {
503
+ const { stdout: upstreamCommit } = await execAsync(
504
+ `git rev-parse ${upstream}`,
505
+ { cwd: this.gitRoot }
506
+ );
507
+ this.baseCommit = upstreamCommit.trim();
508
+
509
+ const diffs = await Promise.all(
510
+ uniqueFiles.map(fp => this.getDiffInfoForFile(fp))
511
+ );
512
+
513
+ return diffs.flat();
514
+ } finally {
515
+ this.baseCommit = originalBaseCommit;
516
+ }
517
+ } catch (error) {
518
+ console.error('Error getting unpushed changes:', error.message);
519
+ return this._getUncommittedChanges();
520
+ }
521
+ }
522
+
523
+ async getUnpushedCommits() {
524
+ if (!this.gitRoot) {
525
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
526
+ }
527
+
528
+ try {
529
+ let upstream;
530
+ try {
531
+ const { stdout } = await execAsync(
532
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
533
+ { cwd: this.gitRoot }
534
+ );
535
+ upstream = stdout.trim();
536
+ } catch (error) {
537
+ // Find alternative upstream
538
+ const { stdout: currentBranch } = await execAsync(
539
+ 'git rev-parse --abbrev-ref HEAD',
540
+ { cwd: this.gitRoot }
541
+ );
542
+
543
+ const { stdout: remoteBranches } = await execAsync(
544
+ `git ls-remote --heads origin ${currentBranch.trim()}`,
545
+ { cwd: this.gitRoot }
546
+ );
547
+
548
+ if (remoteBranches.trim()) {
549
+ upstream = `origin/${currentBranch.trim()}`;
550
+ } else {
551
+ upstream = 'origin/main';
552
+ }
553
+ }
554
+
555
+ const { stdout: commitsStr } = await execAsync(
556
+ `git log ${upstream}..HEAD --pretty=format:"%H|%s|%an|%ar"`,
557
+ { cwd: this.gitRoot }
558
+ );
559
+
560
+ if (!commitsStr.trim()) {
561
+ return [];
562
+ }
563
+
564
+ return commitsStr
565
+ .split('\n')
566
+ .filter(Boolean)
567
+ .map(line => {
568
+ const [hash, message, author, date] = line.split('|');
569
+ return { hash, message, author, date };
570
+ });
571
+ } catch (error) {
572
+ console.error('Error getting unpushed commits:', error.message);
573
+ return [];
574
+ }
575
+ }
576
+
577
+ _generateRandomHash() {
578
+ return Math.random().toString(36).substring(2, 9) + Math.random().toString(36).substring(2, 9);
579
+ }
580
+
581
+ async getFileDiff(filePath) {
582
+ if (!this.gitRoot || !this.baseCommit) {
583
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
584
+ }
585
+
586
+ let diff = '';
587
+
588
+ try {
589
+ await execAsync(`git ls-files --error-unmatch "${filePath}"`, {
590
+ cwd: this.gitRoot,
591
+ });
592
+
593
+ const { stdout } = await execAsync(
594
+ `git diff ${this.baseCommit} -- "${filePath}"`,
595
+ { cwd: this.gitRoot }
596
+ );
597
+ diff = stdout;
598
+ } catch (err) {
599
+ // Handle untracked files
600
+ try {
601
+ const absoluteFilePath = path.resolve(this.gitRoot, filePath);
602
+ const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
603
+ const lines = headFileStr.split('\n');
604
+
605
+ let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
606
+ patchStr += `new file mode 100644\n`;
607
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
608
+ patchStr += `--- /dev/null\n`;
609
+ patchStr += `+++ b/${filePath}\n`;
610
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
611
+
612
+ lines.forEach(line => {
613
+ patchStr += `+${line}\n`;
614
+ });
615
+
616
+ diff = patchStr;
617
+ } catch (manualDiffError) {
618
+ diff = '';
619
+ }
620
+ }
621
+
622
+ return diff;
623
+ }
624
+
625
+ async getAllDiffs() {
626
+ if (!this.gitRoot || !this.baseCommit) {
627
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
628
+ }
629
+
630
+ const { stdout: fullDiff } = await execAsync(
631
+ `git diff ${this.baseCommit}`,
632
+ { cwd: this.gitRoot }
633
+ );
634
+
635
+ return fullDiff;
636
+ }
637
+
638
+ async getDiffInfoForFile(filePath) {
639
+ if (!this.gitRoot || !this.baseCommit) {
640
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
641
+ }
642
+
643
+ // Get base and head file contents
644
+ const baseFileStr = await execAsync(
645
+ `git show ${this.baseCommit}:"${filePath}"`,
646
+ { cwd: this.gitRoot }
647
+ ).then(r => r.stdout).catch(() => '');
648
+
649
+ const headFileStr = await fs.readFile(
650
+ path.join(this.gitRoot, filePath), 'utf8'
651
+ ).catch(() => '');
652
+
653
+ // Get patch
654
+ const patchStr = await this.getFileDiff(filePath);
655
+
656
+ // Get file status
657
+ const { stdout: nameStatus } = await execAsync(
658
+ `git diff --name-status ${this.baseCommit} -- "${filePath}"`,
659
+ { cwd: this.gitRoot }
660
+ ).catch(() => ({ stdout: '' }));
661
+
662
+ let editTypeStr = 'MODIFIED';
663
+ let oldFilenameStr = null;
664
+ let filenameStr = filePath;
665
+
666
+ if (nameStatus.trim()) {
667
+ const [statusCode, oldName, newName] = nameStatus.trim().split(/\t+/);
668
+ if (statusCode.startsWith('A')) editTypeStr = 'ADDED';
669
+ else if (statusCode.startsWith('D')) editTypeStr = 'DELETED';
670
+ else if (statusCode.startsWith('R')) {
671
+ editTypeStr = 'RENAMED';
672
+ oldFilenameStr = oldName;
673
+ filenameStr = newName;
674
+ }
675
+ }
676
+
677
+ // Get line counts
678
+ let numPlusLinesStr = '0', numMinusLinesStr = '0';
679
+ try {
680
+ const { stdout: ns } = await execAsync(
681
+ `git diff --numstat ${this.baseCommit} -- "${filePath}"`,
682
+ { cwd: this.gitRoot }
683
+ );
684
+ if (ns.trim()) [numPlusLinesStr, numMinusLinesStr] = ns.split('\t');
685
+ } catch (_) { /* ignore */ }
686
+
687
+ const tokensStr = headFileStr ? String(headFileStr.split(/\s+/).length) : '0';
688
+
689
+ // Parse hunks
690
+ const hunkHeader = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
691
+ const results = [];
692
+ let headerMatch;
693
+
694
+ while ((headerMatch = hunkHeader.exec(patchStr)) !== null) {
695
+ const newStart = Number(headerMatch[1]);
696
+ const newCount = headerMatch[2] ? Number(headerMatch[2]) : 1;
697
+ const hunkStartIdx = headerMatch.index;
698
+
699
+ const nextHeaderIdx = patchStr.slice(hunkHeader.lastIndex).search(/^@@/m);
700
+ const hunkEndIdx = nextHeaderIdx === -1
701
+ ? patchStr.length
702
+ : hunkHeader.lastIndex + nextHeaderIdx;
703
+ const singleHunkPatch = patchStr.slice(hunkStartIdx, hunkEndIdx);
704
+
705
+ results.push({
706
+ base_file_str: baseFileStr,
707
+ head_file_str: headFileStr,
708
+ patch_str: singleHunkPatch,
709
+ filename_str: filenameStr,
710
+ edit_type_str: editTypeStr,
711
+ old_filename_str: oldFilenameStr,
712
+ num_plus_lines_str: numPlusLinesStr,
713
+ num_minus_lines_str: numMinusLinesStr,
714
+ tokens_str: tokensStr,
715
+ start_line_str: String(newStart),
716
+ end_line_str: String(newStart + newCount - 1),
717
+ });
718
+ }
719
+
720
+ // If no hunks, return single item
721
+ if (results.length === 0) {
722
+ results.push({
723
+ base_file_str: baseFileStr,
724
+ head_file_str: headFileStr,
725
+ patch_str: patchStr,
726
+ filename_str: filenameStr,
727
+ edit_type_str: editTypeStr,
728
+ old_filename_str: oldFilenameStr,
729
+ num_plus_lines_str: numPlusLinesStr,
730
+ num_minus_lines_str: numMinusLinesStr,
731
+ tokens_str: tokensStr,
732
+ start_line_str: '',
733
+ end_line_str: '',
734
+ });
735
+ }
736
+
737
+ return results;
738
+ }
739
+
740
+ getLocalBranch() {
741
+ if (!this.currentBranch) {
742
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
743
+ }
744
+ return this.currentBranch;
745
+ }
746
+
747
+ getGitRoot() {
748
+ return this.gitRoot;
749
+ }
750
+
751
+ async getAllDiffInfo() {
752
+ const changedFiles = await this.getChangedFiles();
753
+
754
+ const perFileHunks = await Promise.all(
755
+ changedFiles.map(fp => this.getDiffInfoForFile(fp))
756
+ );
757
+
758
+ return perFileHunks.flat();
759
+ }
760
+ }
761
+
762
+ export default GitDiffHelper;