difit 3.1.9 → 3.1.10

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.
@@ -13,19 +13,19 @@ vi.mock('./utils.js', async () => {
13
13
  promptUser: vi.fn(),
14
14
  findUntrackedFiles: vi.fn(),
15
15
  markFilesIntentToAdd: vi.fn(),
16
- resolvePrCommits: vi.fn(),
16
+ getPrPatch: vi.fn(),
17
17
  };
18
18
  });
19
19
  const { simpleGit } = await import('simple-git');
20
20
  const { startServer } = await import('../server/server.js');
21
- const { promptUser, findUntrackedFiles, markFilesIntentToAdd, resolvePrCommits } = await import('./utils.js');
21
+ const { promptUser, findUntrackedFiles, markFilesIntentToAdd, getPrPatch } = await import('./utils.js');
22
22
  describe('CLI index.ts', () => {
23
23
  let mockGit;
24
24
  let mockStartServer;
25
25
  let mockPromptUser;
26
26
  let mockFindUntrackedFiles;
27
27
  let mockMarkFilesIntentToAdd;
28
- let mockResolvePrCommits;
28
+ let mockGetPrPatch;
29
29
  // Store original console methods
30
30
  let originalConsoleLog;
31
31
  let originalConsoleError;
@@ -46,7 +46,7 @@ describe('CLI index.ts', () => {
46
46
  mockPromptUser = vi.mocked(promptUser);
47
47
  mockFindUntrackedFiles = vi.mocked(findUntrackedFiles);
48
48
  mockMarkFilesIntentToAdd = vi.mocked(markFilesIntentToAdd);
49
- mockResolvePrCommits = vi.mocked(resolvePrCommits);
49
+ mockGetPrPatch = vi.mocked(getPrPatch);
50
50
  // Mock console and process.exit
51
51
  originalConsoleLog = console.log;
52
52
  originalConsoleError = console.error;
@@ -194,6 +194,11 @@ describe('CLI index.ts', () => {
194
194
  args: ['--clean'],
195
195
  expectedOptions: { clean: true },
196
196
  },
197
+ {
198
+ name: '--keep-alive option',
199
+ args: ['--keep-alive'],
200
+ expectedOptions: { keepAlive: true },
201
+ },
197
202
  ])('$name', async ({ args, expectedOptions }) => {
198
203
  mockFindUntrackedFiles.mockResolvedValue([]);
199
204
  const program = new Command();
@@ -207,6 +212,7 @@ describe('CLI index.ts', () => {
207
212
  .option('--tui', 'tui')
208
213
  .option('--pr <url>', 'pr')
209
214
  .option('--clean', 'start with a clean slate by clearing all existing comments')
215
+ .option('--keep-alive', 'keep server running even after browser disconnects')
210
216
  .action(async (commitish, _compareWith, options) => {
211
217
  let targetCommitish = commitish;
212
218
  let baseCommitish = commitish + '^';
@@ -218,6 +224,7 @@ describe('CLI index.ts', () => {
218
224
  openBrowser: options.open,
219
225
  mode: options.mode,
220
226
  clearComments: options.clean,
227
+ keepAlive: options.keepAlive,
221
228
  });
222
229
  });
223
230
  await program.parseAsync([...args], { from: 'user' });
@@ -229,6 +236,7 @@ describe('CLI index.ts', () => {
229
236
  openBrowser: expectedOptions.open !== false,
230
237
  mode: expectedOptions.mode || 'split',
231
238
  clearComments: expectedOptions.clean,
239
+ keepAlive: expectedOptions.keepAlive,
232
240
  };
233
241
  expect(mockStartServer).toHaveBeenCalledWith(expectedCall);
234
242
  });
@@ -372,13 +380,10 @@ describe('CLI index.ts', () => {
372
380
  });
373
381
  });
374
382
  describe('GitHub PR integration', () => {
375
- it('resolves PR commits correctly', async () => {
383
+ it('loads PR patch with gh and starts server with stdin diff', async () => {
376
384
  const prUrl = 'https://github.com/owner/repo/pull/123';
377
- const prCommits = {
378
- targetCommitish: 'abc123',
379
- baseCommitish: 'def456',
380
- };
381
- mockResolvePrCommits.mockResolvedValue(prCommits);
385
+ const prPatch = 'diff --git a/file.ts b/file.ts\nindex 1111111..2222222 100644\n';
386
+ mockGetPrPatch.mockReturnValue(prPatch);
382
387
  const program = new Command();
383
388
  program
384
389
  .argument('[commit-ish]', 'commit-ish', 'HEAD')
@@ -390,23 +395,14 @@ describe('CLI index.ts', () => {
390
395
  .option('--tui', 'tui')
391
396
  .option('--pr <url>', 'pr')
392
397
  .action(async (commitish, _compareWith, options) => {
393
- let targetCommitish = commitish;
394
- let baseCommitish;
395
398
  if (options.pr) {
396
399
  if (commitish !== 'HEAD' || _compareWith) {
397
400
  console.error('Error: --pr option cannot be used with positional arguments');
398
401
  process.exit(1);
399
402
  }
400
- const prCommits = await resolvePrCommits(options.pr);
401
- targetCommitish = prCommits.targetCommitish;
402
- baseCommitish = prCommits.baseCommitish;
403
- }
404
- else {
405
- baseCommitish = commitish + '^';
406
403
  }
407
404
  await startServer({
408
- targetCommitish,
409
- baseCommitish,
405
+ stdinDiff: getPrPatch(options.pr),
410
406
  preferredPort: options.port,
411
407
  host: options.host,
412
408
  openBrowser: options.open,
@@ -414,10 +410,9 @@ describe('CLI index.ts', () => {
414
410
  });
415
411
  });
416
412
  await program.parseAsync(['--pr', prUrl], { from: 'user' });
417
- expect(mockResolvePrCommits).toHaveBeenCalledWith(prUrl);
413
+ expect(mockGetPrPatch).toHaveBeenCalledWith(prUrl);
418
414
  expect(mockStartServer).toHaveBeenCalledWith({
419
- targetCommitish: 'abc123',
420
- baseCommitish: 'def456',
415
+ stdinDiff: prPatch,
421
416
  preferredPort: undefined,
422
417
  host: '',
423
418
  openBrowser: true,
@@ -449,13 +444,8 @@ describe('CLI index.ts', () => {
449
444
  expect(console.error).toHaveBeenCalledWith('Error: --pr option cannot be used with positional arguments');
450
445
  expect(process.exit).toHaveBeenCalledWith(1);
451
446
  });
452
- it('resolves GitHub Enterprise PR commits correctly', async () => {
453
- const prUrl = 'https://github.enterprise.com/owner/repo/pull/456';
454
- const prCommits = {
455
- targetCommitish: 'xyz789',
456
- baseCommitish: 'uvw012',
457
- };
458
- mockResolvePrCommits.mockResolvedValue(prCommits);
447
+ it('rejects PR option with --tui', async () => {
448
+ const prUrl = 'https://github.com/owner/repo/pull/123';
459
449
  const program = new Command();
460
450
  program
461
451
  .argument('[commit-ish]', 'commit-ish', 'HEAD')
@@ -467,39 +457,21 @@ describe('CLI index.ts', () => {
467
457
  .option('--tui', 'tui')
468
458
  .option('--pr <url>', 'pr')
469
459
  .action(async (commitish, _compareWith, options) => {
470
- let targetCommitish = commitish;
471
- let baseCommitish;
472
460
  if (options.pr) {
473
461
  if (commitish !== 'HEAD' || _compareWith) {
474
462
  console.error('Error: --pr option cannot be used with positional arguments');
475
463
  process.exit(1);
476
464
  }
477
- const prCommits = await resolvePrCommits(options.pr);
478
- targetCommitish = prCommits.targetCommitish;
479
- baseCommitish = prCommits.baseCommitish;
480
- }
481
- else {
482
- baseCommitish = commitish + '^';
465
+ if (options.tui) {
466
+ console.error('Error: --pr option cannot be used with --tui');
467
+ process.exit(1);
468
+ }
483
469
  }
484
- await startServer({
485
- targetCommitish,
486
- baseCommitish,
487
- preferredPort: options.port,
488
- host: options.host,
489
- openBrowser: options.open,
490
- mode: options.mode,
491
- });
492
- });
493
- await program.parseAsync(['--pr', prUrl], { from: 'user' });
494
- expect(mockResolvePrCommits).toHaveBeenCalledWith(prUrl);
495
- expect(mockStartServer).toHaveBeenCalledWith({
496
- targetCommitish: 'xyz789',
497
- baseCommitish: 'uvw012',
498
- preferredPort: undefined,
499
- host: '',
500
- openBrowser: true,
501
- mode: 'split',
502
470
  });
471
+ await program.parseAsync(['--pr', prUrl, '--tui'], { from: 'user' });
472
+ expect(console.error).toHaveBeenCalledWith('Error: --pr option cannot be used with --tui');
473
+ expect(process.exit).toHaveBeenCalledWith(1);
474
+ expect(mockStartServer).not.toHaveBeenCalled();
503
475
  });
504
476
  });
505
477
  describe('Clean flag functionality', () => {
@@ -584,6 +556,92 @@ describe('CLI index.ts', () => {
584
556
  expect(console.log).not.toHaveBeenCalledWith('🧹 Starting with a clean slate - all existing comments will be cleared');
585
557
  });
586
558
  });
559
+ describe('Keep-alive flag functionality', () => {
560
+ it('displays keep-alive message when flag is used', async () => {
561
+ mockFindUntrackedFiles.mockResolvedValue([]);
562
+ mockStartServer.mockResolvedValue({
563
+ port: 4966,
564
+ url: 'http://localhost:4966',
565
+ isEmpty: false,
566
+ });
567
+ const program = new Command();
568
+ program
569
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
570
+ .argument('[compare-with]', 'compare-with')
571
+ .option('--port <port>', 'port', parseInt)
572
+ .option('--host <host>', 'host', '')
573
+ .option('--no-open', 'no-open')
574
+ .option('--mode <mode>', 'mode', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
575
+ .option('--tui', 'tui')
576
+ .option('--pr <url>', 'pr')
577
+ .option('--clean', 'start with a clean slate by clearing all existing comments')
578
+ .option('--keep-alive', 'keep server running even after browser disconnects')
579
+ .action(async (commitish, _compareWith, options) => {
580
+ const { url } = await startServer({
581
+ targetCommitish: commitish,
582
+ baseCommitish: commitish + '^',
583
+ preferredPort: options.port,
584
+ host: options.host,
585
+ openBrowser: options.open,
586
+ mode: options.mode,
587
+ clearComments: options.clean,
588
+ keepAlive: options.keepAlive,
589
+ });
590
+ console.log(`\n🚀 difit server started on ${url}`);
591
+ console.log(`📋 Reviewing: ${commitish}`);
592
+ if (options.keepAlive) {
593
+ console.log('🔒 Keep-alive mode: server will stay running after browser disconnects');
594
+ }
595
+ });
596
+ await program.parseAsync(['--keep-alive'], { from: 'user' });
597
+ expect(mockStartServer).toHaveBeenCalledWith(expect.objectContaining({
598
+ keepAlive: true,
599
+ }));
600
+ expect(console.log).toHaveBeenCalledWith('🔒 Keep-alive mode: server will stay running after browser disconnects');
601
+ });
602
+ it('does not display keep-alive message when flag is not used', async () => {
603
+ mockFindUntrackedFiles.mockResolvedValue([]);
604
+ mockStartServer.mockResolvedValue({
605
+ port: 4966,
606
+ url: 'http://localhost:4966',
607
+ isEmpty: false,
608
+ });
609
+ const program = new Command();
610
+ program
611
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
612
+ .argument('[compare-with]', 'compare-with')
613
+ .option('--port <port>', 'port', parseInt)
614
+ .option('--host <host>', 'host', '')
615
+ .option('--no-open', 'no-open')
616
+ .option('--mode <mode>', 'mode', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
617
+ .option('--tui', 'tui')
618
+ .option('--pr <url>', 'pr')
619
+ .option('--clean', 'start with a clean slate by clearing all existing comments')
620
+ .option('--keep-alive', 'keep server running even after browser disconnects')
621
+ .action(async (commitish, _compareWith, options) => {
622
+ const { url } = await startServer({
623
+ targetCommitish: commitish,
624
+ baseCommitish: commitish + '^',
625
+ preferredPort: options.port,
626
+ host: options.host,
627
+ openBrowser: options.open,
628
+ mode: options.mode,
629
+ clearComments: options.clean,
630
+ keepAlive: options.keepAlive,
631
+ });
632
+ console.log(`\n🚀 difit server started on ${url}`);
633
+ console.log(`📋 Reviewing: ${commitish}`);
634
+ if (options.keepAlive) {
635
+ console.log('🔒 Keep-alive mode: server will stay running after browser disconnects');
636
+ }
637
+ });
638
+ await program.parseAsync([], { from: 'user' });
639
+ expect(mockStartServer).toHaveBeenCalledWith(expect.objectContaining({
640
+ keepAlive: undefined,
641
+ }));
642
+ expect(console.log).not.toHaveBeenCalledWith('🔒 Keep-alive mode: server will stay running after browser disconnects');
643
+ });
644
+ });
587
645
  describe('Console output', () => {
588
646
  it('displays server startup message with correct URL', async () => {
589
647
  mockFindUntrackedFiles.mockResolvedValue([]);
@@ -22,10 +22,7 @@ export interface PullRequestInfo {
22
22
  hostname: string;
23
23
  }
24
24
  export declare function parseGitHubPrUrl(url: string): PullRequestInfo | null;
25
- export declare function resolvePrCommits(prUrl: string): Promise<{
26
- targetCommitish: string;
27
- baseCommitish: string;
28
- }>;
25
+ export declare function getPrPatch(prArg: string): string;
29
26
  export declare function validateDiffArguments(targetCommitish: string, baseCommitish?: string): {
30
27
  valid: boolean;
31
28
  error?: string;
package/dist/cli/utils.js CHANGED
@@ -1,7 +1,6 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync, execSync } from 'child_process';
2
2
  import { fstatSync } from 'node:fs';
3
3
  import { createInterface } from 'readline/promises';
4
- import { Octokit } from '@octokit/rest';
5
4
  export function detectStdinSource(stdinStat = fstatSync(0)) {
6
5
  if (stdinStat.isFIFO()) {
7
6
  return 'pipe';
@@ -122,107 +121,27 @@ export function parseGitHubPrUrl(url) {
122
121
  return null;
123
122
  }
124
123
  }
125
- function getGitHubToken() {
126
- // Try to get token from environment variable first
127
- if (process.env.GITHUB_TOKEN) {
128
- return process.env.GITHUB_TOKEN;
129
- }
130
- // Try to get token from GitHub CLI
124
+ export function getPrPatch(prArg) {
131
125
  try {
132
- const result = execSync('gh auth token', { encoding: 'utf8', stdio: 'pipe' });
133
- return result.trim();
134
- }
135
- catch {
136
- // GitHub CLI not available or not authenticated
137
- return undefined;
138
- }
139
- }
140
- async function fetchPrDetails(prInfo) {
141
- const token = getGitHubToken();
142
- const octokitOptions = {
143
- auth: token,
144
- };
145
- // For GitHub Enterprise, set the base URL
146
- if (prInfo.hostname !== 'github.com') {
147
- octokitOptions.baseUrl = `https://${prInfo.hostname}/api/v3`;
148
- }
149
- const octokit = new Octokit(octokitOptions);
150
- try {
151
- const { data: pr } = await octokit.rest.pulls.get({
152
- owner: prInfo.owner,
153
- repo: prInfo.repo,
154
- pull_number: prInfo.pullNumber,
126
+ const patch = execFileSync('gh', ['pr', 'diff', prArg, '--patch'], {
127
+ encoding: 'utf8',
128
+ stdio: ['ignore', 'pipe', 'pipe'],
155
129
  });
156
- return {
157
- baseSha: pr.base.sha,
158
- headSha: pr.head.sha,
159
- baseRef: pr.base.ref,
160
- headRef: pr.head.ref,
161
- };
162
- }
163
- catch (error) {
164
- if (error instanceof Error) {
165
- let authHint = '';
166
- // Provide more specific error messages for authentication issues
167
- if (error.message.includes('Bad credentials')) {
168
- if (prInfo.hostname !== 'github.com') {
169
- authHint = `\n\nFor GitHub Enterprise Server (${prInfo.hostname}):
170
- 1. Generate a token on YOUR Enterprise Server: https://${prInfo.hostname}/settings/tokens
171
- 2. Set it as GITHUB_TOKEN environment variable
172
- 3. Tokens from github.com will NOT work on Enterprise servers`;
173
- }
174
- else {
175
- authHint = '\n\nTry: gh auth login or set GITHUB_TOKEN environment variable';
176
- }
177
- }
178
- else if (!token) {
179
- authHint = ' (Try: gh auth login or set GITHUB_TOKEN environment variable)';
180
- }
181
- throw new Error(`Failed to fetch PR details: ${error.message}${authHint}`);
182
- }
183
- throw new Error('Failed to fetch PR details: Unknown error');
184
- }
185
- }
186
- function resolveCommitInLocalRepo(sha, context) {
187
- try {
188
- // Verify if the commit exists locally
189
- execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
190
- return sha;
191
- }
192
- catch {
193
- // If commit doesn't exist, try to fetch from remote
194
- try {
195
- execSync('git fetch origin', { stdio: 'ignore' });
196
- execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
197
- return sha;
198
- }
199
- catch {
200
- const errorMessage = [
201
- `Commit ${sha} not found in local repository.`,
202
- '',
203
- 'Common causes:',
204
- ' • Are you running this command in the correct repository directory?',
205
- context ? ` • Expected repository: ${context.owner}/${context.repo}` : '',
206
- ' • Is this PR from a fork?',
207
- ' • Try: git remote add upstream <original-repo-url> && git fetch upstream',
208
- ' • Try: git fetch --all to fetch from all remotes',
209
- ]
210
- .filter(Boolean)
211
- .join('\n');
212
- throw new Error(errorMessage);
130
+ if (!patch.trim()) {
131
+ throw new Error('No patch content returned from gh pr diff --patch');
213
132
  }
133
+ return patch;
214
134
  }
215
- }
216
- export async function resolvePrCommits(prUrl) {
217
- const prInfo = parseGitHubPrUrl(prUrl);
218
- if (!prInfo) {
219
- throw new Error('Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123 or https://github.enterprise.com/owner/repo/pull/123');
135
+ catch (error) {
136
+ const stderr = error.stderr;
137
+ const stderrText = typeof stderr === 'string'
138
+ ? stderr.trim()
139
+ : Buffer.isBuffer(stderr)
140
+ ? stderr.toString('utf8').trim()
141
+ : '';
142
+ const message = stderrText || (error instanceof Error ? error.message : 'Unknown error while running gh');
143
+ throw new Error(`${message}\nTry: gh auth login`);
220
144
  }
221
- const prDetails = await fetchPrDetails(prInfo);
222
- const context = { owner: prInfo.owner, repo: prInfo.repo };
223
- const targetCommitish = resolveCommitInLocalRepo(prDetails.headSha, context);
224
- const baseCommitish = resolveCommitInLocalRepo(prDetails.baseSha, context);
225
- return { targetCommitish, baseCommitish };
226
145
  }
227
146
  export function validateDiffArguments(targetCommitish, baseCommitish) {
228
147
  // Validate target commitish format