codeant-cli 0.3.3 → 0.3.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.
Files changed (76) hide show
  1. package/package.json +8 -10
  2. package/src/commands/getBaseUrl.js +21 -0
  3. package/src/commands/login.js +120 -0
  4. package/src/commands/logout.js +30 -0
  5. package/src/commands/review.js +294 -0
  6. package/src/commands/secrets.js +107 -0
  7. package/src/commands/setBaseUrl.js +37 -0
  8. package/src/components/ReviewUI.js +410 -0
  9. package/src/components/SecretsUI.js +366 -0
  10. package/src/components/Welcome.js +144 -0
  11. package/src/index.js +390 -0
  12. package/src/reviewHeadless.js +198 -0
  13. package/src/rules/secrets.toml +4994 -0
  14. package/src/scm/azure/client.js +21 -0
  15. package/src/scm/azure/getCodeReview.js +20 -0
  16. package/src/scm/azure/getPullRequest.js +30 -0
  17. package/src/scm/azure/index.js +7 -0
  18. package/src/scm/azure/listCodeReviews.js +17 -0
  19. package/src/scm/azure/listPullRequestComments.js +41 -0
  20. package/src/scm/azure/listPullRequests.js +26 -0
  21. package/src/scm/azure/resolveConversation.js +22 -0
  22. package/src/scm/azure/searchComments.js +35 -0
  23. package/src/scm/bitbucket/client.js +18 -0
  24. package/src/scm/bitbucket/getCodeReview.js +3 -0
  25. package/src/scm/bitbucket/getPullRequest.js +26 -0
  26. package/src/scm/bitbucket/index.js +7 -0
  27. package/src/scm/bitbucket/listCodeReviews.js +17 -0
  28. package/src/scm/bitbucket/listPullRequestComments.js +33 -0
  29. package/src/scm/bitbucket/listPullRequests.js +33 -0
  30. package/src/scm/bitbucket/resolveConversation.js +21 -0
  31. package/src/scm/bitbucket/searchComments.js +29 -0
  32. package/src/scm/github/client.js +17 -0
  33. package/src/scm/github/getCodeReview.js +26 -0
  34. package/src/scm/github/getPullRequest.js +27 -0
  35. package/src/scm/github/index.js +7 -0
  36. package/src/scm/github/listCodeReviews.js +17 -0
  37. package/src/scm/github/listPullRequestComments.js +51 -0
  38. package/src/scm/github/listPullRequests.js +32 -0
  39. package/src/scm/github/resolveConversation.js +85 -0
  40. package/src/scm/github/searchComments.js +30 -0
  41. package/src/scm/gitlab/client.js +16 -0
  42. package/src/scm/gitlab/getCodeReview.js +18 -0
  43. package/src/scm/gitlab/getPullRequest.js +24 -0
  44. package/src/scm/gitlab/index.js +7 -0
  45. package/src/scm/gitlab/listCodeReviews.js +21 -0
  46. package/src/scm/gitlab/listPullRequestComments.js +44 -0
  47. package/src/scm/gitlab/listPullRequests.js +26 -0
  48. package/src/scm/gitlab/resolveConversation.js +14 -0
  49. package/src/scm/gitlab/searchComments.js +28 -0
  50. package/src/scm/index.js +97 -0
  51. package/src/tools/executeTool.js +31 -0
  52. package/src/tools/globTool.js +9 -0
  53. package/src/tools/grepTool.js +18 -0
  54. package/src/tools/lsTool.js +7 -0
  55. package/src/tools/readTool.js +12 -0
  56. package/src/utils/analytics.js +56 -0
  57. package/src/utils/baseUrl.js +11 -0
  58. package/src/utils/commonApiHelper.js +143 -0
  59. package/src/utils/config.js +42 -0
  60. package/src/utils/fetchApi.js +39 -0
  61. package/src/utils/gitDiffHelper.js +1203 -0
  62. package/src/utils/reviewApiHelper.js +291 -0
  63. package/src/utils/scmAuth.js +136 -0
  64. package/src/utils/secretsApiHelper.js +252 -0
  65. package/src/utils/secretsDetector.js +210 -0
  66. package/dist/178.index.js +0 -1
  67. package/dist/239.index.js +0 -1
  68. package/dist/701.index.js +0 -1
  69. package/dist/880.index.js +0 -1
  70. package/dist/935.index.js +0 -1
  71. package/dist/966.index.js +0 -1
  72. package/dist/index.js +0 -28
  73. package/dist/package.json +0 -1
  74. package/dist/review/239.index.js +0 -1
  75. package/dist/review/index.js +0 -8
  76. package/dist/review/package.json +0 -3
@@ -0,0 +1,1203 @@
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
+ /**
9
+ * Expand untracked paths from `git status --porcelain`.
10
+ * Git reports untracked directories as a single entry (e.g. "tests/"),
11
+ * not the individual files inside. This helper resolves directory entries
12
+ * into their individual file paths recursively.
13
+ */
14
+ async function expandUntrackedPaths(gitRoot, paths) {
15
+ const result = [];
16
+ for (const p of paths) {
17
+ const abs = path.resolve(gitRoot, p);
18
+ try {
19
+ const stat = await fs.stat(abs);
20
+ if (stat.isDirectory()) {
21
+ const entries = await fs.readdir(abs, { withFileTypes: true, recursive: true });
22
+ for (const entry of entries) {
23
+ if (entry.isFile()) {
24
+ const entryPath = path.join(entry.parentPath || entry.path, entry.name);
25
+ result.push(path.relative(gitRoot, entryPath));
26
+ }
27
+ }
28
+ } else {
29
+ result.push(p);
30
+ }
31
+ } catch {
32
+ result.push(p);
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+
38
+ class GitDiffHelper {
39
+ constructor(workspacePath) {
40
+ this.workspacePath = workspacePath;
41
+ this.gitRoot = null;
42
+ this.currentBranch = null;
43
+ this.defaultBranch = null;
44
+ this.baseCommit = null;
45
+ this._headRef = null; // When set, getDiffInfoForFile reads head content from this git ref instead of disk
46
+ }
47
+
48
+ /**
49
+ * Find the git root directory by traversing up from the workspace path
50
+ */
51
+ async findGitRoot(directory) {
52
+ try {
53
+ // First check current directory
54
+ const files = await fs.readdir(directory);
55
+ if (files.includes('.git')) {
56
+ return directory;
57
+ }
58
+
59
+ // Traverse up to parent directories
60
+ let currentDir = directory;
61
+ while (currentDir !== path.dirname(currentDir)) {
62
+ currentDir = path.dirname(currentDir);
63
+ try {
64
+ const parentFiles = await fs.readdir(currentDir);
65
+ if (parentFiles.includes('.git')) {
66
+ return currentDir;
67
+ }
68
+ } catch (err) {
69
+ break;
70
+ }
71
+ }
72
+
73
+ return null;
74
+ } catch (error) {
75
+ console.error(`Error finding git root: ${error.message}`);
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Initialize the helper: finds git root, current branch, default branch, and base commit
82
+ */
83
+ async init() {
84
+ // Find git root
85
+ this.gitRoot = await this.findGitRoot(this.workspacePath);
86
+ if (!this.gitRoot) {
87
+ throw new Error('Could not find a .git directory.');
88
+ }
89
+
90
+ try {
91
+ // Try to fetch origin (silently fails if no remote)
92
+ await this.fetchOrigin();
93
+ } catch (err) {
94
+ // No remote, continue anyway
95
+ }
96
+
97
+ try {
98
+ // Get current branch
99
+ const { stdout: branchName } = await execAsync(
100
+ 'git rev-parse --abbrev-ref HEAD',
101
+ { cwd: this.gitRoot }
102
+ );
103
+ this.currentBranch = branchName.trim();
104
+ } catch (err) {
105
+ this.currentBranch = 'main';
106
+ }
107
+
108
+ // Determine default branch
109
+ try {
110
+ const { stdout: remoteBranch } = await execAsync(
111
+ 'git rev-parse --abbrev-ref origin/HEAD',
112
+ { cwd: this.gitRoot }
113
+ );
114
+ this.defaultBranch = remoteBranch.trim().replace('origin/', '');
115
+ } catch (err) {
116
+ this.defaultBranch = 'main';
117
+ }
118
+
119
+ // Find merge base commit or fallback to HEAD
120
+ try {
121
+ const { stdout: mergeBase } = await execAsync(
122
+ `git merge-base ${this.currentBranch} origin/${this.defaultBranch}`,
123
+ { cwd: this.gitRoot }
124
+ );
125
+ this.baseCommit = mergeBase.trim();
126
+ } catch (err) {
127
+ // Fallback to HEAD if no merge base found
128
+ try {
129
+ const { stdout: head } = await execAsync(
130
+ 'git rev-parse HEAD',
131
+ { cwd: this.gitRoot }
132
+ );
133
+ this.baseCommit = head.trim();
134
+ } catch (headErr) {
135
+ // No commits yet - set to empty tree
136
+ this.baseCommit = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
137
+ }
138
+ }
139
+ }
140
+
141
+ async fetchOrigin() {
142
+ try {
143
+ await execAsync('git fetch origin', { cwd: this.gitRoot });
144
+ } catch (err) {
145
+ // Silently continue if fetch fails (e.g., offline)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Get all branches (local and remote)
151
+ */
152
+ async getAllBranches() {
153
+ if (!this.gitRoot) {
154
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
155
+ }
156
+
157
+ const { stdout: branchesStr } = await execAsync(
158
+ 'git branch -a --format="%(refname:short)"',
159
+ { cwd: this.gitRoot }
160
+ );
161
+
162
+ const branches = branchesStr
163
+ .split('\n')
164
+ .map(b => b.trim())
165
+ .filter(Boolean)
166
+ .map(b => b.replace('origin/', ''))
167
+ .filter((value, index, self) => self.indexOf(value) === index);
168
+
169
+ return branches;
170
+ }
171
+
172
+ /**
173
+ * Get recent commits with metadata
174
+ */
175
+ async getRecentCommits(limit = 10) {
176
+ if (!this.gitRoot) {
177
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
178
+ }
179
+
180
+ const { stdout: commitsStr } = await execAsync(
181
+ `git log --pretty=format:"%H|%s|%an|%ar" -n ${limit}`,
182
+ { cwd: this.gitRoot }
183
+ );
184
+
185
+ const commits = commitsStr
186
+ .split('\n')
187
+ .filter(Boolean)
188
+ .map(line => {
189
+ const [hash, message, author, date] = line.split('|');
190
+ return { hash, message, author, date };
191
+ });
192
+
193
+ return commits;
194
+ }
195
+
196
+ /**
197
+ * Get files changed since merge base
198
+ */
199
+ async getChangedFiles() {
200
+ if (!this.gitRoot || !this.baseCommit) {
201
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
202
+ }
203
+
204
+ const cmd = [
205
+ `git diff --name-only --diff-filter=ACMRD ${this.baseCommit}`,
206
+ `git diff --name-only --cached --diff-filter=ACMRD`,
207
+ `git ls-files --others --exclude-standard`
208
+ ].join(' && ');
209
+
210
+ const { stdout: changedFiles } = await execAsync(cmd, { cwd: this.gitRoot });
211
+
212
+ const uniqueFiles = Array.from(new Set(
213
+ changedFiles
214
+ .split('\n')
215
+ .map(f => f.trim())
216
+ .filter(Boolean)
217
+ ));
218
+
219
+ return uniqueFiles;
220
+ }
221
+
222
+ /**
223
+ * Get staged files only
224
+ */
225
+ async getStagedFiles() {
226
+ if (!this.gitRoot) {
227
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
228
+ }
229
+
230
+ const { stdout: stagedFiles } = await execAsync(
231
+ 'git diff --name-only --cached',
232
+ { cwd: this.gitRoot }
233
+ );
234
+
235
+ return stagedFiles
236
+ .split('\n')
237
+ .map(f => f.trim())
238
+ .filter(Boolean);
239
+ }
240
+
241
+ /**
242
+ * Get diff based on review configuration
243
+ */
244
+ async getDiffBasedOnReviewConfig(reviewConfig = null) {
245
+ if (!this.gitRoot) {
246
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
247
+ }
248
+
249
+ const config = reviewConfig || {
250
+ type: 'branch-diff',
251
+ targetBranch: this.defaultBranch,
252
+ commits: null
253
+ };
254
+
255
+ switch (config.type) {
256
+ case 'branch-diff':
257
+ return this.getAllDiffInfo();
258
+
259
+ case 'last-commit':
260
+ return this._getLastNCommitDiff(1);
261
+
262
+ case 'last-n-commits':
263
+ const n = config.commits || 1;
264
+ return this._getLastNCommitDiff(n);
265
+
266
+ case 'select-commits':
267
+ if (!config.commits || config.commits.length === 0) {
268
+ return [];
269
+ }
270
+ return this._getSpecificCommitsDiff(config.commits);
271
+
272
+ case 'uncommitted':
273
+ return this._getUncommittedChanges();
274
+
275
+ case 'staged-only':
276
+ return this._getStagedChanges();
277
+
278
+ case 'committed':
279
+ return this._getCommittedOnlyDiff();
280
+
281
+ case 'all':
282
+ return this._getAllChangesDiff();
283
+
284
+ case 'base-branch':
285
+ return this._getBaseBranchDiff(config.baseBranch);
286
+
287
+ case 'base-commit':
288
+ return this._getBaseCommitDiff(config.baseCommit);
289
+
290
+ case 'unpushed':
291
+ return this.getUnpushedChangesDiff();
292
+
293
+ default:
294
+ return this._getAllChangesDiff();
295
+ }
296
+ }
297
+
298
+ async _getLastNCommitDiff(n) {
299
+ if (!this.gitRoot) {
300
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
301
+ }
302
+
303
+ // Clamp n between 1 and 5
304
+ n = Math.min(Math.max(1, n), 5);
305
+
306
+ // First check if there are any commits
307
+ try {
308
+ await execAsync('git rev-parse HEAD', { cwd: this.gitRoot });
309
+ } catch (err) {
310
+ // No commits at all
311
+ return [];
312
+ }
313
+
314
+ const originalBaseCommit = this.baseCommit;
315
+
316
+ try {
317
+ // Check if HEAD~n exists (more than n commits)
318
+ await execAsync(`git rev-parse HEAD~${n}`, { cwd: this.gitRoot });
319
+
320
+ const { stdout: changedFiles } = await execAsync(
321
+ `git diff --name-only HEAD~${n} HEAD`,
322
+ { cwd: this.gitRoot }
323
+ );
324
+
325
+ const files = changedFiles
326
+ .split('\n')
327
+ .map(f => f.trim())
328
+ .filter(Boolean);
329
+
330
+ if (files.length === 0) {
331
+ return [];
332
+ }
333
+
334
+ this.baseCommit = `HEAD~${n}`;
335
+ this._headRef = 'HEAD'; // Read file content from git HEAD, not working tree
336
+
337
+ const diffs = await Promise.all(
338
+ files.map(fp => this.getDiffInfoForFile(fp))
339
+ );
340
+
341
+ this.baseCommit = originalBaseCommit;
342
+ this._headRef = null;
343
+ return diffs.flat();
344
+ } catch (err) {
345
+ // HEAD~n doesn't exist - fewer than n commits, include all available
346
+ try {
347
+ const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
348
+ const { stdout: changedFiles } = await execAsync(
349
+ `git diff --name-only ${emptyTree} HEAD`,
350
+ { cwd: this.gitRoot }
351
+ );
352
+
353
+ const files = changedFiles
354
+ .split('\n')
355
+ .map(f => f.trim())
356
+ .filter(Boolean);
357
+
358
+ if (files.length === 0) {
359
+ return [];
360
+ }
361
+
362
+ // Compare against empty tree to capture all commits
363
+ this.baseCommit = emptyTree;
364
+ this._headRef = 'HEAD'; // Read file content from git HEAD, not working tree
365
+
366
+ const diffs = await Promise.all(
367
+ files.map(fp => this.getDiffInfoForFile(fp))
368
+ );
369
+
370
+ this.baseCommit = originalBaseCommit;
371
+ this._headRef = null;
372
+ return diffs.flat();
373
+ } catch (innerErr) {
374
+ console.error('Error getting last commit diff:', innerErr.message);
375
+ this.baseCommit = originalBaseCommit;
376
+ this._headRef = null;
377
+ return [];
378
+ }
379
+ }
380
+ }
381
+
382
+ async _getSpecificCommitsDiff(commitHashes) {
383
+ const allDiffs = [];
384
+ const originalBaseCommit = this.baseCommit;
385
+
386
+ for (const hash of commitHashes) {
387
+ try {
388
+ const { stdout: changedFiles } = await execAsync(
389
+ `git diff --name-only ${hash}~1 ${hash}`,
390
+ { cwd: this.gitRoot }
391
+ );
392
+
393
+ const files = changedFiles
394
+ .split('\n')
395
+ .map(f => f.trim())
396
+ .filter(Boolean);
397
+
398
+ this.baseCommit = `${hash}~1`;
399
+ this._headRef = hash; // Read file content from this commit, not working tree
400
+ const commitDiffs = await Promise.all(
401
+ files.map(fp => this.getDiffInfoForFile(fp))
402
+ );
403
+
404
+ allDiffs.push(...commitDiffs.flat());
405
+ } catch (error) {
406
+ console.error(`Error processing commit ${hash}:`, error.message);
407
+ }
408
+ }
409
+
410
+ this.baseCommit = originalBaseCommit;
411
+ this._headRef = null;
412
+ return allDiffs;
413
+ }
414
+
415
+ async _getUncommittedChanges() {
416
+ const { stdout: allModified } = await execAsync(
417
+ 'git status --porcelain',
418
+ { cwd: this.gitRoot }
419
+ );
420
+
421
+ // Separate tracked vs untracked (??) files — untracked have no HEAD version so git diff skips them
422
+ const trackedFiles = [];
423
+ const untrackedFiles = [];
424
+ for (const line of allModified.split('\n')) {
425
+ if (!line.trim()) continue;
426
+ const statusCode = line.substring(0, 2);
427
+ const filePath = line.substring(3).trim();
428
+ if (!filePath) continue;
429
+ if (statusCode === '??') {
430
+ untrackedFiles.push(filePath);
431
+ } else {
432
+ trackedFiles.push(filePath);
433
+ }
434
+ }
435
+
436
+ // Expand untracked directory entries into individual files
437
+ const expandedUntrackedFiles = await expandUntrackedPaths(this.gitRoot, untrackedFiles);
438
+
439
+ const originalBaseCommit = this.baseCommit;
440
+ this.baseCommit = 'HEAD';
441
+
442
+ const diffs = [];
443
+
444
+ // Handle tracked files via git diff
445
+ for (const file of trackedFiles) {
446
+ try {
447
+ const { stdout: patchStr } = await execAsync(
448
+ `git diff HEAD -- "${file}"`,
449
+ { cwd: this.gitRoot }
450
+ );
451
+
452
+ if (patchStr) {
453
+ const diffInfo = await this.getDiffInfoForFile(file);
454
+ diffs.push(...diffInfo);
455
+ }
456
+ } catch (error) {
457
+ // Skip files that can't be diffed
458
+ }
459
+ }
460
+
461
+ // Handle untracked files by generating synthetic "new file" diffs since git diff can't see them
462
+ for (const file of expandedUntrackedFiles) {
463
+ try {
464
+ const absolutePath = path.resolve(this.gitRoot, file);
465
+ const content = await fs.readFile(absolutePath, 'utf8');
466
+ const lines = content.split('\n');
467
+
468
+ let patchStr = `diff --git a/${file} b/${file}\n`;
469
+ patchStr += `new file mode 100644\n`;
470
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
471
+ patchStr += `--- /dev/null\n`;
472
+ patchStr += `+++ b/${file}\n`;
473
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
474
+ lines.forEach(line => { patchStr += `+${line}\n`; });
475
+
476
+ diffs.push({
477
+ base_file_str: '',
478
+ head_file_str: content,
479
+ patch_str: patchStr,
480
+ filename_str: file,
481
+ edit_type_str: 'ADDED',
482
+ old_filename_str: null,
483
+ num_plus_lines_str: String(lines.length),
484
+ num_minus_lines_str: '0',
485
+ tokens_str: String(content.split(/\s+/).length),
486
+ start_line_str: '1',
487
+ end_line_str: String(lines.length),
488
+ });
489
+ } catch (error) {
490
+ // Skip files that can't be read
491
+ }
492
+ }
493
+
494
+ this.baseCommit = originalBaseCommit;
495
+ return diffs;
496
+ }
497
+
498
+ async _getStagedChanges() {
499
+ const { stdout: stagedFiles } = await execAsync(
500
+ 'git diff --name-only --cached HEAD',
501
+ { cwd: this.gitRoot }
502
+ );
503
+
504
+ const files = stagedFiles
505
+ .split('\n')
506
+ .map(f => f.trim())
507
+ .filter(Boolean);
508
+
509
+ const diffs = [];
510
+ const originalBaseCommit = this.baseCommit;
511
+
512
+ for (const file of files) {
513
+ try {
514
+ this.baseCommit = 'HEAD';
515
+ const diffInfo = await this.getDiffInfoForFile(file);
516
+ diffs.push(...diffInfo);
517
+ } catch (error) {
518
+ console.error(`Error processing staged file ${file}:`, error.message);
519
+ }
520
+ }
521
+
522
+ this.baseCommit = originalBaseCommit;
523
+ return diffs;
524
+ }
525
+
526
+ /**
527
+ * Get diff against a specific base branch.
528
+ * Finds the merge-base between the given branch and HEAD, then diffs from there.
529
+ */
530
+ async _getBaseBranchDiff(branch) {
531
+ if (!this.gitRoot) {
532
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
533
+ }
534
+ if (!branch) {
535
+ throw new Error('Base branch is required for base-branch diff.');
536
+ }
537
+
538
+ // Resolve the branch ref (try with and without origin/ prefix)
539
+ let resolvedRef;
540
+ for (const candidate of [branch, `origin/${branch}`]) {
541
+ try {
542
+ const { stdout } = await execAsync(`git rev-parse ${candidate}`, { cwd: this.gitRoot });
543
+ resolvedRef = stdout.trim();
544
+ break;
545
+ } catch (err) {
546
+ // try next
547
+ }
548
+ }
549
+
550
+ if (!resolvedRef) {
551
+ throw new Error(`Could not resolve branch: ${branch}`);
552
+ }
553
+
554
+ // Find merge-base between the branch and HEAD
555
+ let base;
556
+ try {
557
+ const { stdout } = await execAsync(`git merge-base ${resolvedRef} HEAD`, { cwd: this.gitRoot });
558
+ base = stdout.trim();
559
+ } catch (err) {
560
+ // If no merge-base, use the resolved ref directly
561
+ base = resolvedRef;
562
+ }
563
+
564
+ const { stdout: changedFiles } = await execAsync(
565
+ `git diff --name-only ${base} HEAD`,
566
+ { cwd: this.gitRoot }
567
+ );
568
+
569
+ const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
570
+ if (files.length === 0) return [];
571
+
572
+ const originalBaseCommit = this.baseCommit;
573
+ try {
574
+ this.baseCommit = base;
575
+ this._headRef = 'HEAD';
576
+
577
+ const diffs = await Promise.all(
578
+ files.map(fp => this.getDiffInfoForFile(fp))
579
+ );
580
+ return diffs.flat();
581
+ } finally {
582
+ this.baseCommit = originalBaseCommit;
583
+ this._headRef = null;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Get diff against a specific base commit.
589
+ * Diffs from the given commit to HEAD.
590
+ */
591
+ async _getBaseCommitDiff(commit) {
592
+ if (!this.gitRoot) {
593
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
594
+ }
595
+ if (!commit) {
596
+ throw new Error('Base commit is required for base-commit diff.');
597
+ }
598
+
599
+ // Resolve the commit ref (supports HEAD~3, sha, tag, etc.)
600
+ let resolvedCommit;
601
+ try {
602
+ const { stdout } = await execAsync(`git rev-parse ${commit}`, { cwd: this.gitRoot });
603
+ resolvedCommit = stdout.trim();
604
+ } catch (err) {
605
+ throw new Error(`Could not resolve commit: ${commit}`);
606
+ }
607
+
608
+ const { stdout: changedFiles } = await execAsync(
609
+ `git diff --name-only ${resolvedCommit} HEAD`,
610
+ { cwd: this.gitRoot }
611
+ );
612
+
613
+ const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
614
+ if (files.length === 0) return [];
615
+
616
+ const originalBaseCommit = this.baseCommit;
617
+ try {
618
+ this.baseCommit = resolvedCommit;
619
+ this._headRef = 'HEAD';
620
+
621
+ const diffs = await Promise.all(
622
+ files.map(fp => this.getDiffInfoForFile(fp))
623
+ );
624
+ return diffs.flat();
625
+ } finally {
626
+ this.baseCommit = originalBaseCommit;
627
+ this._headRef = null;
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Get diff of only committed (unpushed) changes — no working tree modifications.
633
+ * Compares upstream..HEAD using git refs so the working tree is ignored.
634
+ */
635
+ async _getCommittedOnlyDiff() {
636
+ if (!this.gitRoot) {
637
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
638
+ }
639
+
640
+ const upstream = await this._resolveUpstream();
641
+ if (!upstream) {
642
+ // No upstream found — fall back to empty (nothing to compare against)
643
+ return [];
644
+ }
645
+
646
+ // Check if there are any unpushed commits
647
+ try {
648
+ const { stdout: commitCount } = await execAsync(
649
+ `git rev-list --count ${upstream}..HEAD`,
650
+ { cwd: this.gitRoot }
651
+ );
652
+ if (parseInt(commitCount.trim(), 10) === 0) {
653
+ return [];
654
+ }
655
+ } catch (err) {
656
+ return [];
657
+ }
658
+
659
+ const { stdout: changedFiles } = await execAsync(
660
+ `git diff --name-only ${upstream}..HEAD`,
661
+ { cwd: this.gitRoot }
662
+ );
663
+
664
+ const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
665
+ if (files.length === 0) return [];
666
+
667
+ const originalBaseCommit = this.baseCommit;
668
+ try {
669
+ const { stdout: upstreamCommit } = await execAsync(
670
+ `git rev-parse ${upstream}`,
671
+ { cwd: this.gitRoot }
672
+ );
673
+ this.baseCommit = upstreamCommit.trim();
674
+ this._headRef = 'HEAD';
675
+
676
+ const diffs = await Promise.all(
677
+ files.map(fp => this.getDiffInfoForFile(fp))
678
+ );
679
+ return diffs.flat();
680
+ } finally {
681
+ this.baseCommit = originalBaseCommit;
682
+ this._headRef = null;
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Get all changes: committed (unpushed) + uncommitted (staged + unstaged + untracked).
688
+ * Equivalent to CodeRabbit's --type all.
689
+ */
690
+ async _getAllChangesDiff() {
691
+ if (!this.gitRoot) {
692
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
693
+ }
694
+
695
+ const upstream = await this._resolveUpstream();
696
+
697
+ if (!upstream) {
698
+ // No upstream — just return uncommitted changes
699
+ return this._getUncommittedChanges();
700
+ }
701
+
702
+ // Get files changed in unpushed commits
703
+ let committedFiles = [];
704
+ try {
705
+ const { stdout } = await execAsync(
706
+ `git diff --name-only ${upstream}..HEAD`,
707
+ { cwd: this.gitRoot }
708
+ );
709
+ committedFiles = stdout.split('\n').map(f => f.trim()).filter(Boolean);
710
+ } catch (err) {
711
+ // ignore
712
+ }
713
+
714
+ // Get uncommitted files (staged + unstaged + untracked)
715
+ const { stdout: porcelain } = await execAsync(
716
+ 'git status --porcelain',
717
+ { cwd: this.gitRoot }
718
+ );
719
+ const uncommittedFiles = [];
720
+ const untrackedFiles = [];
721
+ for (const line of porcelain.split('\n')) {
722
+ if (!line.trim()) continue;
723
+ const statusCode = line.substring(0, 2);
724
+ const filePath = line.substring(3).trim();
725
+ if (!filePath) continue;
726
+ if (statusCode === '??') {
727
+ untrackedFiles.push(filePath);
728
+ } else {
729
+ uncommittedFiles.push(filePath);
730
+ }
731
+ }
732
+
733
+ // Expand untracked directory entries into individual files
734
+ const expandedUntrackedFiles = await expandUntrackedPaths(this.gitRoot, untrackedFiles);
735
+
736
+ // Combine all unique file paths
737
+ const allFiles = Array.from(new Set([...committedFiles, ...uncommittedFiles]));
738
+
739
+ if (allFiles.length === 0 && expandedUntrackedFiles.length === 0) {
740
+ return [];
741
+ }
742
+
743
+ const diffs = [];
744
+ const originalBaseCommit = this.baseCommit;
745
+
746
+ try {
747
+ // For tracked files, diff against upstream
748
+ const { stdout: upstreamCommit } = await execAsync(
749
+ `git rev-parse ${upstream}`,
750
+ { cwd: this.gitRoot }
751
+ );
752
+ this.baseCommit = upstreamCommit.trim();
753
+
754
+ for (const file of allFiles) {
755
+ try {
756
+ const diffInfo = await this.getDiffInfoForFile(file);
757
+ diffs.push(...diffInfo);
758
+ } catch (err) {
759
+ // skip
760
+ }
761
+ }
762
+
763
+ // Handle untracked files with synthetic diffs
764
+ for (const file of expandedUntrackedFiles) {
765
+ try {
766
+ const absolutePath = path.resolve(this.gitRoot, file);
767
+ const content = await fs.readFile(absolutePath, 'utf8');
768
+ const lines = content.split('\n');
769
+
770
+ let patchStr = `diff --git a/${file} b/${file}\n`;
771
+ patchStr += `new file mode 100644\n`;
772
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
773
+ patchStr += `--- /dev/null\n`;
774
+ patchStr += `+++ b/${file}\n`;
775
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
776
+ lines.forEach(line => { patchStr += `+${line}\n`; });
777
+
778
+ diffs.push({
779
+ base_file_str: '',
780
+ head_file_str: content,
781
+ patch_str: patchStr,
782
+ filename_str: file,
783
+ edit_type_str: 'ADDED',
784
+ old_filename_str: null,
785
+ num_plus_lines_str: String(lines.length),
786
+ num_minus_lines_str: '0',
787
+ tokens_str: String(content.split(/\s+/).length),
788
+ start_line_str: '1',
789
+ end_line_str: String(lines.length),
790
+ });
791
+ } catch (err) {
792
+ // skip
793
+ }
794
+ }
795
+ } finally {
796
+ this.baseCommit = originalBaseCommit;
797
+ }
798
+
799
+ return diffs;
800
+ }
801
+
802
+ /**
803
+ * Resolve the upstream ref for the current branch.
804
+ * Returns null if no upstream can be determined.
805
+ */
806
+ async _resolveUpstream() {
807
+ try {
808
+ const { stdout } = await execAsync(
809
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
810
+ { cwd: this.gitRoot }
811
+ );
812
+ return stdout.trim();
813
+ } catch (err) {
814
+ // No tracking branch — try origin/<current-branch>
815
+ }
816
+
817
+ try {
818
+ const { stdout: currentBranch } = await execAsync(
819
+ 'git rev-parse --abbrev-ref HEAD',
820
+ { cwd: this.gitRoot }
821
+ );
822
+ const { stdout: remoteBranches } = await execAsync(
823
+ 'git branch -r',
824
+ { cwd: this.gitRoot }
825
+ );
826
+ if (remoteBranches.includes(`origin/${currentBranch.trim()}`)) {
827
+ return `origin/${currentBranch.trim()}`;
828
+ }
829
+ } catch (err) {
830
+ // ignore
831
+ }
832
+
833
+ // Fall back to origin/main or origin/master
834
+ for (const branch of ['origin/main', 'origin/master']) {
835
+ try {
836
+ await execAsync(`git rev-parse ${branch}`, { cwd: this.gitRoot });
837
+ return branch;
838
+ } catch (err) {
839
+ // try next
840
+ }
841
+ }
842
+
843
+ return null;
844
+ }
845
+
846
+ async getUnpushedChangesDiff() {
847
+ if (!this.gitRoot) {
848
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
849
+ }
850
+
851
+ try {
852
+ const { stdout: currentBranchRaw } = await execAsync(
853
+ 'git rev-parse --abbrev-ref HEAD',
854
+ { cwd: this.gitRoot }
855
+ );
856
+ const currentBranch = currentBranchRaw.trim();
857
+
858
+ let upstream = null;
859
+
860
+ try {
861
+ const { stdout: upstreamRaw } = await execAsync(
862
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
863
+ { cwd: this.gitRoot }
864
+ );
865
+ upstream = upstreamRaw.trim();
866
+ } catch (upstreamError) {
867
+ // Try to find remote branch
868
+ try {
869
+ const { stdout: remoteBranches } = await execAsync(
870
+ 'git branch -r',
871
+ { cwd: this.gitRoot }
872
+ );
873
+
874
+ if (remoteBranches.includes(`origin/${currentBranch}`)) {
875
+ upstream = `origin/${currentBranch}`;
876
+ }
877
+ } catch (error) {
878
+ // Ignore
879
+ }
880
+
881
+ if (!upstream) {
882
+ const defaultBranches = ['origin/main', 'origin/master'];
883
+ for (const branch of defaultBranches) {
884
+ try {
885
+ await execAsync(`git rev-parse ${branch}`, { cwd: this.gitRoot });
886
+ upstream = branch;
887
+ break;
888
+ } catch (e) {
889
+ // Try next
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ if (!upstream) {
896
+ return this._getUncommittedChanges();
897
+ }
898
+
899
+ // Get unpushed files
900
+ const { stdout: unpushedFiles } = await execAsync(
901
+ `git diff --name-only ${upstream}..HEAD`,
902
+ { cwd: this.gitRoot }
903
+ );
904
+ const unpushedFilesList = unpushedFiles.split('\n').map(f => f.trim()).filter(Boolean);
905
+
906
+ // Get staged files
907
+ const { stdout: stagedFiles } = await execAsync(
908
+ 'git diff --name-only --cached',
909
+ { cwd: this.gitRoot }
910
+ );
911
+ const stagedFilesList = stagedFiles.split('\n').map(f => f.trim()).filter(Boolean);
912
+
913
+ // Get unstaged files
914
+ const { stdout: unstagedFiles } = await execAsync(
915
+ 'git diff --name-only',
916
+ { cwd: this.gitRoot }
917
+ );
918
+ const unstagedFilesList = unstagedFiles.split('\n').map(f => f.trim()).filter(Boolean);
919
+
920
+ // Combine and deduplicate
921
+ const uniqueFiles = Array.from(new Set([
922
+ ...unpushedFilesList,
923
+ ...stagedFilesList,
924
+ ...unstagedFilesList
925
+ ]));
926
+
927
+ if (uniqueFiles.length === 0) {
928
+ return [];
929
+ }
930
+
931
+ const originalBaseCommit = this.baseCommit;
932
+ try {
933
+ const { stdout: upstreamCommit } = await execAsync(
934
+ `git rev-parse ${upstream}`,
935
+ { cwd: this.gitRoot }
936
+ );
937
+ this.baseCommit = upstreamCommit.trim();
938
+
939
+ const diffs = await Promise.all(
940
+ uniqueFiles.map(fp => this.getDiffInfoForFile(fp))
941
+ );
942
+
943
+ return diffs.flat();
944
+ } finally {
945
+ this.baseCommit = originalBaseCommit;
946
+ }
947
+ } catch (error) {
948
+ console.error('Error getting unpushed changes:', error.message);
949
+ return this._getUncommittedChanges();
950
+ }
951
+ }
952
+
953
+ async getUnpushedCommits() {
954
+ if (!this.gitRoot) {
955
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
956
+ }
957
+
958
+ try {
959
+ let upstream;
960
+ try {
961
+ const { stdout } = await execAsync(
962
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
963
+ { cwd: this.gitRoot }
964
+ );
965
+ upstream = stdout.trim();
966
+ } catch (error) {
967
+ // Find alternative upstream
968
+ const { stdout: currentBranch } = await execAsync(
969
+ 'git rev-parse --abbrev-ref HEAD',
970
+ { cwd: this.gitRoot }
971
+ );
972
+
973
+ const { stdout: remoteBranches } = await execAsync(
974
+ `git ls-remote --heads origin ${currentBranch.trim()}`,
975
+ { cwd: this.gitRoot }
976
+ );
977
+
978
+ if (remoteBranches.trim()) {
979
+ upstream = `origin/${currentBranch.trim()}`;
980
+ } else {
981
+ upstream = 'origin/main';
982
+ }
983
+ }
984
+
985
+ const { stdout: commitsStr } = await execAsync(
986
+ `git log ${upstream}..HEAD --pretty=format:"%H|%s|%an|%ar"`,
987
+ { cwd: this.gitRoot }
988
+ );
989
+
990
+ if (!commitsStr.trim()) {
991
+ return [];
992
+ }
993
+
994
+ return commitsStr
995
+ .split('\n')
996
+ .filter(Boolean)
997
+ .map(line => {
998
+ const [hash, message, author, date] = line.split('|');
999
+ return { hash, message, author, date };
1000
+ });
1001
+ } catch (error) {
1002
+ console.error('Error getting unpushed commits:', error.message);
1003
+ return [];
1004
+ }
1005
+ }
1006
+
1007
+ _generateRandomHash() {
1008
+ return Math.random().toString(36).substring(2, 9) + Math.random().toString(36).substring(2, 9);
1009
+ }
1010
+
1011
+ async getFileDiff(filePath) {
1012
+ if (!this.gitRoot || !this.baseCommit) {
1013
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
1014
+ }
1015
+
1016
+ let diff = '';
1017
+
1018
+ try {
1019
+ await execAsync(`git ls-files --error-unmatch "${filePath}"`, {
1020
+ cwd: this.gitRoot,
1021
+ });
1022
+
1023
+ const { stdout } = await execAsync(
1024
+ `git diff ${this.baseCommit} -- "${filePath}"`,
1025
+ { cwd: this.gitRoot }
1026
+ );
1027
+ diff = stdout;
1028
+ } catch (err) {
1029
+ // Handle untracked files
1030
+ try {
1031
+ const absoluteFilePath = path.resolve(this.gitRoot, filePath);
1032
+ const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
1033
+ const lines = headFileStr.split('\n');
1034
+
1035
+ let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
1036
+ patchStr += `new file mode 100644\n`;
1037
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
1038
+ patchStr += `--- /dev/null\n`;
1039
+ patchStr += `+++ b/${filePath}\n`;
1040
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
1041
+
1042
+ lines.forEach(line => {
1043
+ patchStr += `+${line}\n`;
1044
+ });
1045
+
1046
+ diff = patchStr;
1047
+ } catch (manualDiffError) {
1048
+ diff = '';
1049
+ }
1050
+ }
1051
+
1052
+ return diff;
1053
+ }
1054
+
1055
+ async getAllDiffs() {
1056
+ if (!this.gitRoot || !this.baseCommit) {
1057
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
1058
+ }
1059
+
1060
+ const { stdout: fullDiff } = await execAsync(
1061
+ `git diff ${this.baseCommit}`,
1062
+ { cwd: this.gitRoot }
1063
+ );
1064
+
1065
+ return fullDiff;
1066
+ }
1067
+
1068
+ async getDiffInfoForFile(filePath) {
1069
+ if (!this.gitRoot || !this.baseCommit) {
1070
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
1071
+ }
1072
+
1073
+ // Get base and head file contents
1074
+ const baseFileStr = await execAsync(
1075
+ `git show ${this.baseCommit}:"${filePath}"`,
1076
+ { cwd: this.gitRoot }
1077
+ ).then(r => r.stdout).catch(() => '');
1078
+
1079
+ // For commit-based diffs (e.g. last-commit, last-n-commits), read head from git
1080
+ // to avoid mismatch if the working tree has diverged.
1081
+ // For working-tree diffs (uncommitted, staged), read from disk.
1082
+ let headFileStr;
1083
+ if (this._headRef) {
1084
+ headFileStr = await execAsync(
1085
+ `git show ${this._headRef}:"${filePath}"`,
1086
+ { cwd: this.gitRoot }
1087
+ ).then(r => r.stdout).catch(() => '');
1088
+ } else {
1089
+ headFileStr = await fs.readFile(
1090
+ path.join(this.gitRoot, filePath), 'utf8'
1091
+ ).catch(() => '');
1092
+ }
1093
+
1094
+ // Get patch
1095
+ const patchStr = await this.getFileDiff(filePath);
1096
+
1097
+ // Get file status
1098
+ const { stdout: nameStatus } = await execAsync(
1099
+ `git diff --name-status ${this.baseCommit} -- "${filePath}"`,
1100
+ { cwd: this.gitRoot }
1101
+ ).catch(() => ({ stdout: '' }));
1102
+
1103
+ let editTypeStr = 'MODIFIED';
1104
+ let oldFilenameStr = null;
1105
+ let filenameStr = filePath;
1106
+
1107
+ if (nameStatus.trim()) {
1108
+ const [statusCode, oldName, newName] = nameStatus.trim().split(/\t+/);
1109
+ if (statusCode.startsWith('A')) editTypeStr = 'ADDED';
1110
+ else if (statusCode.startsWith('D')) editTypeStr = 'DELETED';
1111
+ else if (statusCode.startsWith('R')) {
1112
+ editTypeStr = 'RENAMED';
1113
+ oldFilenameStr = oldName;
1114
+ filenameStr = newName;
1115
+ }
1116
+ }
1117
+
1118
+ // Get line counts
1119
+ let numPlusLinesStr = '0', numMinusLinesStr = '0';
1120
+ try {
1121
+ const { stdout: ns } = await execAsync(
1122
+ `git diff --numstat ${this.baseCommit} -- "${filePath}"`,
1123
+ { cwd: this.gitRoot }
1124
+ );
1125
+ if (ns.trim()) [numPlusLinesStr, numMinusLinesStr] = ns.split('\t');
1126
+ } catch (_) { /* ignore */ }
1127
+
1128
+ const tokensStr = headFileStr ? String(headFileStr.split(/\s+/).length) : '0';
1129
+
1130
+ // Parse hunks
1131
+ const hunkHeader = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
1132
+ const results = [];
1133
+ let headerMatch;
1134
+
1135
+ while ((headerMatch = hunkHeader.exec(patchStr)) !== null) {
1136
+ const newStart = Number(headerMatch[1]);
1137
+ const newCount = headerMatch[2] ? Number(headerMatch[2]) : 1;
1138
+ const hunkStartIdx = headerMatch.index;
1139
+
1140
+ const nextHeaderIdx = patchStr.slice(hunkHeader.lastIndex).search(/^@@/m);
1141
+ const hunkEndIdx = nextHeaderIdx === -1
1142
+ ? patchStr.length
1143
+ : hunkHeader.lastIndex + nextHeaderIdx;
1144
+ const singleHunkPatch = patchStr.slice(hunkStartIdx, hunkEndIdx);
1145
+
1146
+ results.push({
1147
+ base_file_str: baseFileStr,
1148
+ head_file_str: headFileStr,
1149
+ patch_str: singleHunkPatch,
1150
+ filename_str: filenameStr,
1151
+ edit_type_str: editTypeStr,
1152
+ old_filename_str: oldFilenameStr,
1153
+ num_plus_lines_str: numPlusLinesStr,
1154
+ num_minus_lines_str: numMinusLinesStr,
1155
+ tokens_str: tokensStr,
1156
+ start_line_str: String(newStart),
1157
+ end_line_str: String(newStart + newCount - 1),
1158
+ });
1159
+ }
1160
+
1161
+ // If no hunks, return single item
1162
+ if (results.length === 0) {
1163
+ results.push({
1164
+ base_file_str: baseFileStr,
1165
+ head_file_str: headFileStr,
1166
+ patch_str: patchStr,
1167
+ filename_str: filenameStr,
1168
+ edit_type_str: editTypeStr,
1169
+ old_filename_str: oldFilenameStr,
1170
+ num_plus_lines_str: numPlusLinesStr,
1171
+ num_minus_lines_str: numMinusLinesStr,
1172
+ tokens_str: tokensStr,
1173
+ start_line_str: '',
1174
+ end_line_str: '',
1175
+ });
1176
+ }
1177
+
1178
+ return results;
1179
+ }
1180
+
1181
+ getLocalBranch() {
1182
+ if (!this.currentBranch) {
1183
+ throw new Error('GitDiffHelper not initialized. Call init() first.');
1184
+ }
1185
+ return this.currentBranch;
1186
+ }
1187
+
1188
+ getGitRoot() {
1189
+ return this.gitRoot;
1190
+ }
1191
+
1192
+ async getAllDiffInfo() {
1193
+ const changedFiles = await this.getChangedFiles();
1194
+
1195
+ const perFileHunks = await Promise.all(
1196
+ changedFiles.map(fp => this.getDiffInfoForFile(fp))
1197
+ );
1198
+
1199
+ return perFileHunks.flat();
1200
+ }
1201
+ }
1202
+
1203
+ export default GitDiffHelper;