difit 0.0.5 → 1.0.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.
Files changed (39) hide show
  1. package/README.md +8 -10
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.test.d.ts +1 -0
  4. package/dist/cli/index.test.js +676 -0
  5. package/dist/cli/utils.d.ts +1 -0
  6. package/dist/cli/utils.js +12 -1
  7. package/dist/cli/utils.test.d.ts +1 -0
  8. package/dist/cli/utils.test.js +214 -0
  9. package/dist/client/assets/index-CGpOyJJl.js +178 -0
  10. package/dist/client/assets/index-CpclbaYk.css +1 -0
  11. package/dist/client/index.html +2 -2
  12. package/dist/server/git-diff-tui.d.ts +2 -0
  13. package/dist/server/git-diff-tui.js +95 -0
  14. package/dist/server/git-diff.d.ts +10 -0
  15. package/dist/server/git-diff.js +23 -5
  16. package/dist/server/git-diff.test.d.ts +1 -0
  17. package/dist/server/git-diff.test.js +292 -0
  18. package/dist/server/server.d.ts +12 -0
  19. package/dist/server/server.js +8 -58
  20. package/dist/server/server.test.d.ts +1 -0
  21. package/dist/server/server.test.js +382 -0
  22. package/dist/tui/App.d.ts +8 -0
  23. package/dist/tui/App.js +92 -0
  24. package/dist/tui/components/DiffViewer.d.ts +9 -0
  25. package/dist/tui/components/DiffViewer.js +88 -0
  26. package/dist/tui/components/FileList.d.ts +8 -0
  27. package/dist/tui/components/FileList.js +48 -0
  28. package/dist/tui/components/SideBySideDiffViewer.d.ts +9 -0
  29. package/dist/tui/components/SideBySideDiffViewer.js +237 -0
  30. package/dist/tui/components/StatusBar.d.ts +8 -0
  31. package/dist/tui/components/StatusBar.js +23 -0
  32. package/dist/tui/utils/parseDiff.d.ts +2 -0
  33. package/dist/tui/utils/parseDiff.js +68 -0
  34. package/dist/types/diff.d.ts +35 -0
  35. package/dist/utils/fileUtils.d.ts +12 -0
  36. package/dist/utils/fileUtils.js +21 -0
  37. package/package.json +1 -1
  38. package/dist/client/assets/index-W2UC55JC.css +0 -1
  39. package/dist/client/assets/index-hiGBtmpa.js +0 -142
@@ -0,0 +1,676 @@
1
+ import { Command } from 'commander';
2
+ import React from 'react';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ // Mock all external dependencies
5
+ vi.mock('simple-git');
6
+ vi.mock('../server/server.js');
7
+ vi.mock('./utils.js', async () => {
8
+ const actual = await vi.importActual('./utils.js');
9
+ return {
10
+ ...actual,
11
+ promptUser: vi.fn(),
12
+ findUntrackedFiles: vi.fn(),
13
+ markFilesIntentToAdd: vi.fn(),
14
+ resolvePrCommits: vi.fn(),
15
+ };
16
+ });
17
+ const { simpleGit } = await import('simple-git');
18
+ const { startServer } = await import('../server/server.js');
19
+ const { promptUser, findUntrackedFiles, markFilesIntentToAdd, resolvePrCommits } = await import('./utils.js');
20
+ describe('CLI index.ts', () => {
21
+ let mockGit;
22
+ let mockStartServer;
23
+ let mockPromptUser;
24
+ let mockFindUntrackedFiles;
25
+ let mockMarkFilesIntentToAdd;
26
+ let mockResolvePrCommits;
27
+ // Store original console methods
28
+ let originalConsoleLog;
29
+ let originalConsoleError;
30
+ let originalProcessExit;
31
+ beforeEach(() => {
32
+ // Setup mocks
33
+ mockGit = {
34
+ status: vi.fn(),
35
+ add: vi.fn(),
36
+ };
37
+ vi.mocked(simpleGit).mockReturnValue(mockGit);
38
+ mockStartServer = vi.mocked(startServer);
39
+ mockStartServer.mockResolvedValue({
40
+ port: 3000,
41
+ url: 'http://localhost:3000',
42
+ isEmpty: false,
43
+ });
44
+ mockPromptUser = vi.mocked(promptUser);
45
+ mockFindUntrackedFiles = vi.mocked(findUntrackedFiles);
46
+ mockMarkFilesIntentToAdd = vi.mocked(markFilesIntentToAdd);
47
+ mockResolvePrCommits = vi.mocked(resolvePrCommits);
48
+ // Mock console and process.exit
49
+ originalConsoleLog = console.log;
50
+ originalConsoleError = console.error;
51
+ originalProcessExit = process.exit;
52
+ console.log = vi.fn();
53
+ console.error = vi.fn();
54
+ process.exit = vi.fn();
55
+ // Reset all mocks
56
+ vi.clearAllMocks();
57
+ });
58
+ afterEach(() => {
59
+ // Restore original methods
60
+ console.log = originalConsoleLog;
61
+ console.error = originalConsoleError;
62
+ process.exit = originalProcessExit;
63
+ });
64
+ describe('CLI argument processing', () => {
65
+ it.each([
66
+ {
67
+ name: 'default arguments',
68
+ args: [],
69
+ expectedTarget: 'HEAD',
70
+ expectedBase: 'HEAD^',
71
+ },
72
+ {
73
+ name: 'single commit argument',
74
+ args: ['main'],
75
+ expectedTarget: 'main',
76
+ expectedBase: 'main^',
77
+ },
78
+ {
79
+ name: 'two commit arguments',
80
+ args: ['main', 'develop'],
81
+ expectedTarget: 'main',
82
+ expectedBase: 'develop',
83
+ },
84
+ {
85
+ name: 'special: working',
86
+ args: ['working'],
87
+ expectedTarget: 'working',
88
+ expectedBase: 'staged',
89
+ },
90
+ {
91
+ name: 'special: staged',
92
+ args: ['staged'],
93
+ expectedTarget: 'staged',
94
+ expectedBase: 'HEAD',
95
+ },
96
+ {
97
+ name: 'special: dot',
98
+ args: ['.'],
99
+ expectedTarget: '.',
100
+ expectedBase: 'HEAD',
101
+ },
102
+ ])('$name', async ({ args, expectedTarget, expectedBase }) => {
103
+ mockFindUntrackedFiles.mockResolvedValue([]);
104
+ const program = new Command();
105
+ // Simulate command execution
106
+ program
107
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
108
+ .argument('[compare-with]', 'compare-with')
109
+ .option('--port <port>', 'port', parseInt)
110
+ .option('--host <host>', 'host', '127.0.0.1')
111
+ .option('--no-open', 'no-open')
112
+ .option('--mode <mode>', 'mode', 'side-by-side')
113
+ .option('--tui', 'tui')
114
+ .option('--pr <url>', 'pr')
115
+ .action(async (commitish, _compareWith, options) => {
116
+ // Simulate the logic from index.ts
117
+ let targetCommitish = commitish;
118
+ let baseCommitish;
119
+ if (_compareWith) {
120
+ baseCommitish = _compareWith;
121
+ }
122
+ else {
123
+ if (commitish === 'working') {
124
+ baseCommitish = 'staged';
125
+ }
126
+ else if (commitish === 'staged' || commitish === '.') {
127
+ baseCommitish = 'HEAD';
128
+ }
129
+ else {
130
+ baseCommitish = commitish + '^';
131
+ }
132
+ }
133
+ if (commitish === 'working' || commitish === '.') {
134
+ const git = simpleGit();
135
+ await findUntrackedFiles(git);
136
+ // Skip prompt logic for test
137
+ }
138
+ await startServer({
139
+ targetCommitish,
140
+ baseCommitish,
141
+ preferredPort: options.port,
142
+ host: options.host,
143
+ openBrowser: options.open,
144
+ mode: options.mode,
145
+ });
146
+ });
147
+ await program.parseAsync([...args], { from: 'user' });
148
+ expect(mockStartServer).toHaveBeenCalledWith({
149
+ targetCommitish: expectedTarget,
150
+ baseCommitish: expectedBase,
151
+ preferredPort: undefined,
152
+ host: '127.0.0.1',
153
+ openBrowser: true,
154
+ mode: 'side-by-side',
155
+ });
156
+ });
157
+ });
158
+ describe('CLI options', () => {
159
+ it.each([
160
+ {
161
+ name: '--port option',
162
+ args: ['--port', '4000'],
163
+ expectedOptions: { port: 4000 },
164
+ },
165
+ {
166
+ name: '--host option',
167
+ args: ['--host', '0.0.0.0'],
168
+ expectedOptions: { host: '0.0.0.0' },
169
+ },
170
+ {
171
+ name: '--no-open option',
172
+ args: ['--no-open'],
173
+ expectedOptions: { open: false },
174
+ },
175
+ {
176
+ name: '--mode option',
177
+ args: ['--mode', 'inline'],
178
+ expectedOptions: { mode: 'inline' },
179
+ },
180
+ ])('$name', async ({ args, expectedOptions }) => {
181
+ mockFindUntrackedFiles.mockResolvedValue([]);
182
+ const program = new Command();
183
+ program
184
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
185
+ .argument('[compare-with]', 'compare-with')
186
+ .option('--port <port>', 'port', parseInt)
187
+ .option('--host <host>', 'host', '127.0.0.1')
188
+ .option('--no-open', 'no-open')
189
+ .option('--mode <mode>', 'mode', 'side-by-side')
190
+ .option('--tui', 'tui')
191
+ .option('--pr <url>', 'pr')
192
+ .action(async (commitish, _compareWith, options) => {
193
+ let targetCommitish = commitish;
194
+ let baseCommitish = commitish + '^';
195
+ await startServer({
196
+ targetCommitish,
197
+ baseCommitish,
198
+ preferredPort: options.port,
199
+ host: options.host,
200
+ openBrowser: options.open,
201
+ mode: options.mode,
202
+ });
203
+ });
204
+ await program.parseAsync([...args], { from: 'user' });
205
+ const expectedCall = {
206
+ targetCommitish: 'HEAD',
207
+ baseCommitish: 'HEAD^',
208
+ preferredPort: expectedOptions.port,
209
+ host: expectedOptions.host || '127.0.0.1',
210
+ openBrowser: expectedOptions.open !== false,
211
+ mode: expectedOptions.mode || 'side-by-side',
212
+ };
213
+ expect(mockStartServer).toHaveBeenCalledWith(expectedCall);
214
+ });
215
+ });
216
+ describe('Git operations', () => {
217
+ it('handles untracked files for working directory', async () => {
218
+ const untrackedFiles = ['file1.js', 'file2.js'];
219
+ mockFindUntrackedFiles.mockResolvedValue(untrackedFiles);
220
+ mockPromptUser.mockResolvedValue(true);
221
+ mockMarkFilesIntentToAdd.mockResolvedValue(undefined);
222
+ const program = new Command();
223
+ program
224
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
225
+ .argument('[compare-with]', 'compare-with')
226
+ .option('--port <port>', 'port', parseInt)
227
+ .option('--host <host>', 'host', '127.0.0.1')
228
+ .option('--no-open', 'no-open')
229
+ .option('--mode <mode>', 'mode', 'side-by-side')
230
+ .option('--tui', 'tui')
231
+ .option('--pr <url>', 'pr')
232
+ .action(async (commitish, _compareWith, options) => {
233
+ if (commitish === 'working' || commitish === '.') {
234
+ const git = simpleGit();
235
+ await findUntrackedFiles(git);
236
+ // Skip prompt logic for test
237
+ }
238
+ await startServer({
239
+ targetCommitish: commitish,
240
+ baseCommitish: 'staged',
241
+ preferredPort: options.port,
242
+ host: options.host,
243
+ openBrowser: options.open,
244
+ mode: options.mode,
245
+ });
246
+ });
247
+ await program.parseAsync(['working'], { from: 'user' });
248
+ expect(mockFindUntrackedFiles).toHaveBeenCalledWith(mockGit);
249
+ // Note: The actual CLI uses promptUserToIncludeUntracked, not promptUser directly
250
+ // This test verifies the Git interaction pattern
251
+ });
252
+ it('skips untracked file handling for regular commits', async () => {
253
+ const program = new Command();
254
+ program
255
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
256
+ .argument('[compare-with]', 'compare-with')
257
+ .option('--port <port>', 'port', parseInt)
258
+ .option('--host <host>', 'host', '127.0.0.1')
259
+ .option('--no-open', 'no-open')
260
+ .option('--mode <mode>', 'mode', 'side-by-side')
261
+ .option('--tui', 'tui')
262
+ .option('--pr <url>', 'pr')
263
+ .action(async (commitish, _compareWith, options) => {
264
+ if (commitish === 'working' || commitish === '.') {
265
+ const git = simpleGit();
266
+ await findUntrackedFiles(git);
267
+ }
268
+ await startServer({
269
+ targetCommitish: commitish,
270
+ baseCommitish: commitish + '^',
271
+ preferredPort: options.port,
272
+ host: options.host,
273
+ openBrowser: options.open,
274
+ mode: options.mode,
275
+ });
276
+ });
277
+ await program.parseAsync(['HEAD'], { from: 'user' });
278
+ expect(mockFindUntrackedFiles).not.toHaveBeenCalled();
279
+ });
280
+ });
281
+ describe('GitHub PR integration', () => {
282
+ it('resolves PR commits correctly', async () => {
283
+ const prUrl = 'https://github.com/owner/repo/pull/123';
284
+ const prCommits = {
285
+ targetCommitish: 'abc123',
286
+ baseCommitish: 'def456',
287
+ };
288
+ mockResolvePrCommits.mockResolvedValue(prCommits);
289
+ const program = new Command();
290
+ program
291
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
292
+ .argument('[compare-with]', 'compare-with')
293
+ .option('--port <port>', 'port', parseInt)
294
+ .option('--host <host>', 'host', '127.0.0.1')
295
+ .option('--no-open', 'no-open')
296
+ .option('--mode <mode>', 'mode', 'side-by-side')
297
+ .option('--tui', 'tui')
298
+ .option('--pr <url>', 'pr')
299
+ .action(async (commitish, _compareWith, options) => {
300
+ let targetCommitish = commitish;
301
+ let baseCommitish;
302
+ if (options.pr) {
303
+ if (commitish !== 'HEAD' || _compareWith) {
304
+ console.error('Error: --pr option cannot be used with positional arguments');
305
+ process.exit(1);
306
+ }
307
+ const prCommits = await resolvePrCommits(options.pr);
308
+ targetCommitish = prCommits.targetCommitish;
309
+ baseCommitish = prCommits.baseCommitish;
310
+ }
311
+ else {
312
+ baseCommitish = commitish + '^';
313
+ }
314
+ await startServer({
315
+ targetCommitish,
316
+ baseCommitish,
317
+ preferredPort: options.port,
318
+ host: options.host,
319
+ openBrowser: options.open,
320
+ mode: options.mode,
321
+ });
322
+ });
323
+ await program.parseAsync(['--pr', prUrl], { from: 'user' });
324
+ expect(mockResolvePrCommits).toHaveBeenCalledWith(prUrl);
325
+ expect(mockStartServer).toHaveBeenCalledWith({
326
+ targetCommitish: 'abc123',
327
+ baseCommitish: 'def456',
328
+ preferredPort: undefined,
329
+ host: '127.0.0.1',
330
+ openBrowser: true,
331
+ mode: 'side-by-side',
332
+ });
333
+ });
334
+ it('rejects PR option with positional arguments', async () => {
335
+ const program = new Command();
336
+ program
337
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
338
+ .argument('[compare-with]', 'compare-with')
339
+ .option('--port <port>', 'port', parseInt)
340
+ .option('--host <host>', 'host', '127.0.0.1')
341
+ .option('--no-open', 'no-open')
342
+ .option('--mode <mode>', 'mode', 'side-by-side')
343
+ .option('--tui', 'tui')
344
+ .option('--pr <url>', 'pr')
345
+ .action(async (commitish, _compareWith, options) => {
346
+ if (options.pr) {
347
+ if (commitish !== 'HEAD' || _compareWith) {
348
+ console.error('Error: --pr option cannot be used with positional arguments');
349
+ process.exit(1);
350
+ }
351
+ }
352
+ });
353
+ await program.parseAsync(['main', '--pr', 'https://github.com/owner/repo/pull/123'], {
354
+ from: 'user',
355
+ });
356
+ expect(console.error).toHaveBeenCalledWith('Error: --pr option cannot be used with positional arguments');
357
+ expect(process.exit).toHaveBeenCalledWith(1);
358
+ });
359
+ });
360
+ describe('Console output', () => {
361
+ it('displays server startup message with correct URL', async () => {
362
+ mockFindUntrackedFiles.mockResolvedValue([]);
363
+ mockStartServer.mockResolvedValue({
364
+ port: 3000,
365
+ url: 'http://localhost:3000',
366
+ isEmpty: false,
367
+ });
368
+ const program = new Command();
369
+ program
370
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
371
+ .argument('[compare-with]', 'compare-with')
372
+ .option('--port <port>', 'port', parseInt)
373
+ .option('--host <host>', 'host', '127.0.0.1')
374
+ .option('--no-open', 'no-open')
375
+ .option('--mode <mode>', 'mode', 'side-by-side')
376
+ .option('--tui', 'tui')
377
+ .option('--pr <url>', 'pr')
378
+ .action(async (commitish, _compareWith, options) => {
379
+ const { url, isEmpty } = await startServer({
380
+ targetCommitish: commitish,
381
+ baseCommitish: commitish + '^',
382
+ preferredPort: options.port,
383
+ host: options.host,
384
+ openBrowser: options.open,
385
+ mode: options.mode,
386
+ });
387
+ console.log(`\n🚀 ReviewIt server started on ${url}`);
388
+ console.log(`📋 Reviewing: ${commitish}`);
389
+ if (isEmpty) {
390
+ console.log('\n! No differences found. Browser will not open automatically.');
391
+ console.log(` Server is running at ${url} if you want to check manually.\n`);
392
+ }
393
+ else if (options.open) {
394
+ console.log('🌐 Opening browser...\n');
395
+ }
396
+ else {
397
+ console.log('💡 Use --open to automatically open browser\n');
398
+ }
399
+ });
400
+ await program.parseAsync([], { from: 'user' });
401
+ expect(console.log).toHaveBeenCalledWith('\n🚀 ReviewIt server started on http://localhost:3000');
402
+ expect(console.log).toHaveBeenCalledWith('📋 Reviewing: HEAD');
403
+ });
404
+ it('displays correct message when no differences found', async () => {
405
+ mockFindUntrackedFiles.mockResolvedValue([]);
406
+ mockStartServer.mockResolvedValue({
407
+ port: 3000,
408
+ url: 'http://localhost:3000',
409
+ isEmpty: true,
410
+ });
411
+ const program = new Command();
412
+ program
413
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
414
+ .argument('[compare-with]', 'compare-with')
415
+ .option('--port <port>', 'port', parseInt)
416
+ .option('--host <host>', 'host', '127.0.0.1')
417
+ .option('--no-open', 'no-open')
418
+ .option('--mode <mode>', 'mode', 'side-by-side')
419
+ .option('--tui', 'tui')
420
+ .option('--pr <url>', 'pr')
421
+ .action(async (commitish, _compareWith, options) => {
422
+ const { url, isEmpty } = await startServer({
423
+ targetCommitish: commitish,
424
+ baseCommitish: commitish + '^',
425
+ preferredPort: options.port,
426
+ host: options.host,
427
+ openBrowser: options.open,
428
+ mode: options.mode,
429
+ });
430
+ console.log(`\n🚀 ReviewIt server started on ${url}`);
431
+ console.log(`📋 Reviewing: ${commitish}`);
432
+ if (isEmpty) {
433
+ console.log('\n! No differences found. Browser will not open automatically.');
434
+ console.log(` Server is running at ${url} if you want to check manually.\n`);
435
+ }
436
+ });
437
+ await program.parseAsync([], { from: 'user' });
438
+ expect(console.log).toHaveBeenCalledWith('\n! No differences found. Browser will not open automatically.');
439
+ expect(console.log).toHaveBeenCalledWith(' Server is running at http://localhost:3000 if you want to check manually.\n');
440
+ });
441
+ });
442
+ describe('Server mode option handling', () => {
443
+ it('passes mode option to startServer', async () => {
444
+ mockFindUntrackedFiles.mockResolvedValue([]);
445
+ const program = new Command();
446
+ program
447
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
448
+ .argument('[compare-with]', 'compare-with')
449
+ .option('--port <port>', 'port', parseInt)
450
+ .option('--host <host>', 'host', '127.0.0.1')
451
+ .option('--no-open', 'no-open')
452
+ .option('--mode <mode>', 'mode', 'side-by-side')
453
+ .option('--tui', 'tui')
454
+ .option('--pr <url>', 'pr')
455
+ .action(async (commitish, _compareWith, options) => {
456
+ await startServer({
457
+ targetCommitish: commitish,
458
+ baseCommitish: commitish + '^',
459
+ preferredPort: options.port,
460
+ host: options.host,
461
+ openBrowser: options.open,
462
+ mode: options.mode,
463
+ });
464
+ });
465
+ await program.parseAsync(['--mode', 'inline'], { from: 'user' });
466
+ expect(mockStartServer).toHaveBeenCalledWith(expect.objectContaining({
467
+ mode: 'inline',
468
+ }));
469
+ });
470
+ it('uses default mode when not specified', async () => {
471
+ mockFindUntrackedFiles.mockResolvedValue([]);
472
+ const program = new Command();
473
+ program
474
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
475
+ .argument('[compare-with]', 'compare-with')
476
+ .option('--port <port>', 'port', parseInt)
477
+ .option('--host <host>', 'host', '127.0.0.1')
478
+ .option('--no-open', 'no-open')
479
+ .option('--mode <mode>', 'mode', 'side-by-side')
480
+ .option('--tui', 'tui')
481
+ .option('--pr <url>', 'pr')
482
+ .action(async (commitish, _compareWith, options) => {
483
+ await startServer({
484
+ targetCommitish: commitish,
485
+ baseCommitish: commitish + '^',
486
+ preferredPort: options.port,
487
+ host: options.host,
488
+ openBrowser: options.open,
489
+ mode: options.mode,
490
+ });
491
+ });
492
+ await program.parseAsync([], { from: 'user' });
493
+ expect(mockStartServer).toHaveBeenCalledWith(expect.objectContaining({
494
+ mode: 'side-by-side',
495
+ }));
496
+ });
497
+ });
498
+ describe('TUI mode', () => {
499
+ let mockRender;
500
+ let mockTuiApp;
501
+ beforeEach(async () => {
502
+ // Mock ink and TUI components
503
+ mockRender = vi.fn();
504
+ mockTuiApp = vi.fn();
505
+ vi.doMock('ink', async () => ({
506
+ render: mockRender,
507
+ }));
508
+ vi.doMock('../tui/App.js', async () => ({
509
+ default: mockTuiApp,
510
+ }));
511
+ // Mock React.createElement for testing
512
+ vi.spyOn(React, 'createElement').mockImplementation((component, props) => ({ component, props }));
513
+ // Mock process.stdin.isTTY
514
+ Object.defineProperty(process.stdin, 'isTTY', {
515
+ value: true,
516
+ configurable: true,
517
+ });
518
+ });
519
+ afterEach(() => {
520
+ vi.doUnmock('ink');
521
+ vi.doUnmock('../tui/App.js');
522
+ vi.restoreAllMocks();
523
+ });
524
+ it('passes arguments to TUI app correctly', async () => {
525
+ mockFindUntrackedFiles.mockResolvedValue([]);
526
+ const program = new Command();
527
+ program
528
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
529
+ .argument('[compare-with]', 'compare-with')
530
+ .option('--port <port>', 'port', parseInt)
531
+ .option('--host <host>', 'host', '127.0.0.1')
532
+ .option('--no-open', 'no-open')
533
+ .option('--mode <mode>', 'mode', 'side-by-side')
534
+ .option('--tui', 'tui')
535
+ .option('--pr <url>', 'pr')
536
+ .action(async (commitish, _compareWith, options) => {
537
+ if (options.tui) {
538
+ if (!process.stdin.isTTY) {
539
+ console.error('Error: TUI mode requires an interactive terminal (TTY).');
540
+ process.exit(1);
541
+ }
542
+ const { render } = await import('ink');
543
+ const { default: TuiApp } = await import('../tui/App.js');
544
+ render(React.createElement(TuiApp, {
545
+ targetCommitish: commitish,
546
+ baseCommitish: commitish + '^',
547
+ mode: options.mode,
548
+ }));
549
+ return;
550
+ }
551
+ });
552
+ await program.parseAsync(['main', '--tui'], { from: 'user' });
553
+ expect(mockRender).toHaveBeenCalledWith({
554
+ component: mockTuiApp,
555
+ props: {
556
+ targetCommitish: 'main',
557
+ baseCommitish: 'main^',
558
+ mode: 'side-by-side',
559
+ },
560
+ });
561
+ });
562
+ it('passes mode option to TUI app', async () => {
563
+ mockFindUntrackedFiles.mockResolvedValue([]);
564
+ const program = new Command();
565
+ program
566
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
567
+ .argument('[compare-with]', 'compare-with')
568
+ .option('--port <port>', 'port', parseInt)
569
+ .option('--host <host>', 'host', '127.0.0.1')
570
+ .option('--no-open', 'no-open')
571
+ .option('--mode <mode>', 'mode', 'side-by-side')
572
+ .option('--tui', 'tui')
573
+ .option('--pr <url>', 'pr')
574
+ .action(async (commitish, _compareWith, options) => {
575
+ if (options.tui) {
576
+ if (!process.stdin.isTTY) {
577
+ console.error('Error: TUI mode requires an interactive terminal (TTY).');
578
+ process.exit(1);
579
+ }
580
+ const { render } = await import('ink');
581
+ const { default: TuiApp } = await import('../tui/App.js');
582
+ render(React.createElement(TuiApp, {
583
+ targetCommitish: commitish,
584
+ baseCommitish: commitish + '^',
585
+ mode: options.mode,
586
+ }));
587
+ return;
588
+ }
589
+ });
590
+ await program.parseAsync(['--tui', '--mode', 'inline'], { from: 'user' });
591
+ expect(mockRender).toHaveBeenCalledWith({
592
+ component: mockTuiApp,
593
+ props: {
594
+ targetCommitish: 'HEAD',
595
+ baseCommitish: 'HEAD^',
596
+ mode: 'inline',
597
+ },
598
+ });
599
+ });
600
+ it('handles special arguments with TUI mode', async () => {
601
+ mockFindUntrackedFiles.mockResolvedValue([]);
602
+ const program = new Command();
603
+ program
604
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
605
+ .argument('[compare-with]', 'compare-with')
606
+ .option('--port <port>', 'port', parseInt)
607
+ .option('--host <host>', 'host', '127.0.0.1')
608
+ .option('--no-open', 'no-open')
609
+ .option('--mode <mode>', 'mode', 'side-by-side')
610
+ .option('--tui', 'tui')
611
+ .option('--pr <url>', 'pr')
612
+ .action(async (commitish, _compareWith, options) => {
613
+ if (options.tui) {
614
+ const { render } = await import('ink');
615
+ const { default: TuiApp } = await import('../tui/App.js');
616
+ let targetCommitish = commitish;
617
+ let baseCommitish;
618
+ if (commitish === 'working') {
619
+ baseCommitish = 'staged';
620
+ }
621
+ else if (commitish === 'staged' || commitish === '.') {
622
+ baseCommitish = 'HEAD';
623
+ }
624
+ else {
625
+ baseCommitish = commitish + '^';
626
+ }
627
+ render(React.createElement(TuiApp, {
628
+ targetCommitish,
629
+ baseCommitish,
630
+ mode: options.mode,
631
+ }));
632
+ return;
633
+ }
634
+ });
635
+ await program.parseAsync(['working', '--tui', '--mode', 'inline'], { from: 'user' });
636
+ expect(mockRender).toHaveBeenCalledWith({
637
+ component: mockTuiApp,
638
+ props: {
639
+ targetCommitish: 'working',
640
+ baseCommitish: 'staged',
641
+ mode: 'inline',
642
+ },
643
+ });
644
+ });
645
+ it('rejects TUI mode in non-TTY environment', async () => {
646
+ // Mock non-TTY environment
647
+ Object.defineProperty(process.stdin, 'isTTY', {
648
+ value: false,
649
+ configurable: true,
650
+ });
651
+ const program = new Command();
652
+ program
653
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
654
+ .argument('[compare-with]', 'compare-with')
655
+ .option('--port <port>', 'port', parseInt)
656
+ .option('--host <host>', 'host', '127.0.0.1')
657
+ .option('--no-open', 'no-open')
658
+ .option('--mode <mode>', 'mode', 'side-by-side')
659
+ .option('--tui', 'tui')
660
+ .option('--pr <url>', 'pr')
661
+ .action(async (_commitish, _compareWith, options) => {
662
+ if (options.tui) {
663
+ if (!process.stdin.isTTY) {
664
+ console.error('Error: TUI mode requires an interactive terminal (TTY).');
665
+ console.error('Try running the command directly in your terminal without piping.');
666
+ process.exit(1);
667
+ }
668
+ }
669
+ });
670
+ await program.parseAsync(['--tui'], { from: 'user' });
671
+ expect(console.error).toHaveBeenCalledWith('Error: TUI mode requires an interactive terminal (TTY).');
672
+ expect(console.error).toHaveBeenCalledWith('Try running the command directly in your terminal without piping.');
673
+ expect(process.exit).toHaveBeenCalledWith(1);
674
+ });
675
+ });
676
+ });
@@ -0,0 +1 @@
1
+ export declare function validateCommitish(commitish: string): boolean;