ts-repo-utils 6.1.0 → 6.2.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 (40) hide show
  1. package/README.md +11 -7
  2. package/dist/cmd/assert-repo-is-clean.mjs +1 -1
  3. package/dist/cmd/check-should-run-type-checks.mjs +1 -1
  4. package/dist/cmd/format-diff-from.mjs +29 -8
  5. package/dist/cmd/format-diff-from.mjs.map +1 -1
  6. package/dist/cmd/format-uncommitted.d.mts +3 -0
  7. package/dist/cmd/format-uncommitted.d.mts.map +1 -0
  8. package/dist/cmd/format-uncommitted.mjs +59 -0
  9. package/dist/cmd/format-uncommitted.mjs.map +1 -0
  10. package/dist/cmd/gen-index-ts.mjs +1 -1
  11. package/dist/functions/diff.d.mts +32 -2
  12. package/dist/functions/diff.d.mts.map +1 -1
  13. package/dist/functions/diff.mjs +47 -29
  14. package/dist/functions/diff.mjs.map +1 -1
  15. package/dist/functions/exec-async.d.mts +2 -2
  16. package/dist/functions/exec-async.d.mts.map +1 -1
  17. package/dist/functions/format.d.mts +20 -11
  18. package/dist/functions/format.d.mts.map +1 -1
  19. package/dist/functions/format.mjs +134 -95
  20. package/dist/functions/format.mjs.map +1 -1
  21. package/dist/functions/index.mjs +2 -2
  22. package/dist/index.mjs +2 -2
  23. package/package.json +2 -2
  24. package/src/cmd/assert-repo-is-clean.mts +1 -1
  25. package/src/cmd/check-should-run-type-checks.mts +1 -1
  26. package/src/cmd/format-diff-from.mts +35 -9
  27. package/src/cmd/format-uncommitted.mts +67 -0
  28. package/src/cmd/gen-index-ts.mts +1 -1
  29. package/src/functions/diff.mts +85 -32
  30. package/src/functions/diff.test.mts +569 -102
  31. package/src/functions/exec-async.mts +2 -2
  32. package/src/functions/exec-async.test.mts +77 -47
  33. package/src/functions/format.mts +224 -141
  34. package/src/functions/format.test.mts +625 -20
  35. package/src/functions/workspace-utils/run-cmd-in-stages.test.mts +266 -0
  36. package/dist/cmd/format-untracked.d.mts +0 -3
  37. package/dist/cmd/format-untracked.d.mts.map +0 -1
  38. package/dist/cmd/format-untracked.mjs +0 -34
  39. package/dist/cmd/format-untracked.mjs.map +0 -1
  40. package/src/cmd/format-untracked.mts +0 -31
@@ -9,9 +9,9 @@ type ExecOptionsCustom = Readonly<{
9
9
  silent?: boolean;
10
10
  }>;
11
11
 
12
- type ExecOptions = DeepReadonly<ExecOptions_ & ExecOptionsCustom>;
12
+ export type ExecOptions = DeepReadonly<ExecOptions_ & ExecOptionsCustom>;
13
13
 
14
- type ExecResult<T extends string | Buffer> = Result<
14
+ export type ExecResult<T extends string | Buffer> = Result<
15
15
  Readonly<{ stdout: T; stderr: T }>,
16
16
  ExecException
17
17
  >;
@@ -448,53 +448,83 @@ describe('exec-async', () => {
448
448
  });
449
449
 
450
450
  test('should demonstrate type equivalence with runtime comparison', async () => {
451
- // Create a type that represents what exec callback receives
452
- type ExecCallbackParams<T extends string | Buffer> = {
453
- error: ExecException | null;
454
- stdout: T;
455
- stderr: T;
456
- };
457
-
458
- // Helper to capture exec callback types
459
- const captureExecTypes = <T extends string | Buffer>(
460
- _encoding?: BufferEncoding | 'buffer' | null,
461
- ): ExecCallbackParams<T> => {
462
- const emptyParams: ExecCallbackParams<T> = {
463
- error: null,
464
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
465
- stdout: undefined as unknown as T,
466
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
467
- stderr: undefined as unknown as T,
468
- };
469
- return emptyParams;
470
- };
471
-
472
- // Default encoding comparison
473
- const _execDefault = captureExecTypes<string>();
474
- const $Default = await $('echo "test"', { silent: true });
475
- if (Result.isOk($Default)) {
476
- expectType<typeof _execDefault.stdout, typeof $Default.value.stdout>(
477
- '=',
478
- );
479
- expectType<typeof _execDefault.stderr, typeof $Default.value.stderr>(
480
- '=',
481
- );
482
- }
483
-
484
- // Buffer encoding comparison
485
- const _execBuffer = captureExecTypes<Buffer>('buffer');
486
- const $Buffer = await $('echo "test"', {
487
- encoding: 'buffer',
488
- silent: true,
489
- });
490
- if (Result.isOk($Buffer)) {
491
- expectType<typeof _execBuffer.stdout, typeof $Buffer.value.stdout>('=');
492
- expectType<typeof _execBuffer.stderr, typeof $Buffer.value.stderr>('=');
493
- }
494
-
495
- // Error type comparison
496
- if (Result.isErr($Default)) {
497
- expectType<ExecException, typeof $Default.value>('=');
451
+ // Mock console functions to suppress output
452
+ const consoleLogSpy = vi
453
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
454
+ .spyOn(console, 'log')
455
+ .mockImplementation(() => {});
456
+
457
+ const consoleErrorSpy = vi
458
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
459
+ .spyOn(console, 'error')
460
+ .mockImplementation(() => {});
461
+
462
+ const stderrWriteSpy = vi
463
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
464
+ .spyOn(process.stderr, 'write')
465
+ .mockImplementation(() => true);
466
+
467
+ try {
468
+ await withSilentEcho(async () => {
469
+ // Create a type that represents what exec callback receives
470
+ type ExecCallbackParams<T extends string | Buffer> = {
471
+ error: ExecException | null;
472
+ stdout: T;
473
+ stderr: T;
474
+ };
475
+
476
+ // Helper to capture exec callback types
477
+ const captureExecTypes = <T extends string | Buffer>(
478
+ _encoding?: BufferEncoding | 'buffer' | null,
479
+ ): ExecCallbackParams<T> => {
480
+ const emptyParams: ExecCallbackParams<T> = {
481
+ error: null,
482
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
483
+ stdout: undefined as unknown as T,
484
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
485
+ stderr: undefined as unknown as T,
486
+ };
487
+ return emptyParams;
488
+ };
489
+
490
+ // Default encoding comparison
491
+ const _execDefault = captureExecTypes<string>();
492
+ const $Default = await $('echo "test"', { silent: true });
493
+ if (Result.isOk($Default)) {
494
+ expectType<
495
+ typeof _execDefault.stdout,
496
+ typeof $Default.value.stdout
497
+ >('=');
498
+ expectType<
499
+ typeof _execDefault.stderr,
500
+ typeof $Default.value.stderr
501
+ >('=');
502
+ }
503
+
504
+ // Buffer encoding comparison
505
+ const _execBuffer = captureExecTypes<Buffer>('buffer');
506
+ const $Buffer = await $('echo "test"', {
507
+ encoding: 'buffer',
508
+ silent: true,
509
+ });
510
+ if (Result.isOk($Buffer)) {
511
+ expectType<typeof _execBuffer.stdout, typeof $Buffer.value.stdout>(
512
+ '=',
513
+ );
514
+ expectType<typeof _execBuffer.stderr, typeof $Buffer.value.stderr>(
515
+ '=',
516
+ );
517
+ }
518
+
519
+ // Error type comparison
520
+ if (Result.isErr($Default)) {
521
+ expectType<ExecException, typeof $Default.value>('=');
522
+ }
523
+ });
524
+ } finally {
525
+ consoleLogSpy.mockRestore();
526
+ consoleErrorSpy.mockRestore();
527
+ stderrWriteSpy.mockRestore();
498
528
  }
499
529
  });
500
530
  });
@@ -1,25 +1,30 @@
1
+ import { type ExecException } from 'node:child_process';
1
2
  import * as prettier from 'prettier';
2
- import { Arr, Result } from 'ts-data-forge';
3
+ import { Arr, isNotUndefined, Result } from 'ts-data-forge';
3
4
  import '../node-global.mjs';
4
- import { getDiffFrom, getUntrackedFiles } from './diff.mjs';
5
+ import {
6
+ getDiffFrom,
7
+ getModifiedFiles,
8
+ getStagedFiles,
9
+ getUntrackedFiles,
10
+ } from './diff.mjs';
5
11
 
6
12
  /**
7
13
  * Format a list of files using Prettier
8
14
  *
9
15
  * @param files - Array of file paths to format
10
- * @returns 'ok' if successful, 'err' if any errors occurred
11
16
  */
12
- export const formatFilesList = async (
17
+ export const formatFiles = async (
13
18
  files: readonly string[],
14
19
  options?: Readonly<{ silent?: boolean }>,
15
- ): Promise<'ok' | 'err'> => {
20
+ ): Promise<Result<undefined, readonly unknown[]>> => {
16
21
  const silent = options?.silent ?? false;
17
22
 
18
23
  if (files.length === 0) {
19
24
  if (!silent) {
20
25
  echo('No files to format');
21
26
  }
22
- return 'ok';
27
+ return Result.ok(undefined);
23
28
  }
24
29
 
25
30
  if (!silent) {
@@ -27,62 +32,97 @@ export const formatFilesList = async (
27
32
  }
28
33
 
29
34
  // Format each file
30
- const results = await Promise.allSettled(
31
- files.map(async (filePath) => {
32
- try {
33
- // Read file content
34
- const content = await fs.readFile(filePath, 'utf8');
35
+ const results: readonly PromiseSettledResult<Result<undefined, unknown>>[] =
36
+ // NOTE: Using Promise.allSettled to ensure all files are processed even if some fail
37
+ await Promise.allSettled(
38
+ files.map(async (filePath) => {
39
+ try {
40
+ // Check if file exists first
41
+ try {
42
+ await fs.access(filePath);
43
+ } catch {
44
+ // File doesn't exist, skip it
45
+ if (!silent) {
46
+ echo(`Skipping non-existent file: ${filePath}`);
47
+ }
48
+ return Result.ok(undefined);
49
+ }
35
50
 
36
- // Resolve prettier config for this file
37
- const prettierOptions = await prettier.resolveConfig(filePath);
51
+ // Read file content
52
+ const content = await fs.readFile(filePath, 'utf8');
38
53
 
39
- // Check if file is ignored by prettier
40
- const fileInfo = await prettier.getFileInfo(filePath, {
41
- ignorePath: '.prettierignore',
42
- });
54
+ // Resolve prettier config for this file
55
+ const prettierOptions = await prettier.resolveConfig(filePath);
43
56
 
44
- if (fileInfo.ignored) {
45
- if (!silent) {
46
- echo(`Skipping ignored file: ${filePath}`);
57
+ // Check if file is ignored by prettier
58
+ const fileInfo = await prettier.getFileInfo(filePath, {
59
+ ignorePath: '.prettierignore',
60
+ });
61
+
62
+ if (fileInfo.ignored) {
63
+ if (!silent) {
64
+ echo(`Skipping ignored file: ${filePath}`);
65
+ }
66
+ return Result.ok(undefined);
47
67
  }
48
- return;
49
- }
50
68
 
51
- // Format the content
52
- const formatted = await prettier.format(content, {
53
- ...prettierOptions,
54
- filepath: filePath,
55
- });
69
+ // Format the content
70
+ const formatted = await prettier.format(content, {
71
+ ...prettierOptions,
72
+ filepath: filePath,
73
+ });
74
+
75
+ // Only write if content changed
76
+ if (formatted === content) {
77
+ if (!silent) {
78
+ echo(`Unchanged: ${filePath}`);
79
+ }
80
+ } else {
81
+ await fs.writeFile(filePath, formatted, 'utf8');
82
+ if (!silent) {
83
+ echo(`Formatted: ${filePath}`);
84
+ }
85
+ }
56
86
 
57
- // Only write if content changed
58
- if (formatted !== content) {
59
- await fs.writeFile(filePath, formatted, 'utf8');
87
+ return Result.ok(undefined);
88
+ } catch (error) {
60
89
  if (!silent) {
61
- echo(`Formatted: ${filePath}`);
90
+ console.error(`Error formatting ${filePath}:`, error);
62
91
  }
92
+ return Result.err(error);
63
93
  }
64
- } catch (error) {
65
- console.error(`Error formatting ${filePath}:`, error);
66
- throw error;
67
- }
68
- }),
69
- );
94
+ }),
95
+ );
96
+
97
+ if (results.every((r) => r.status === 'fulfilled')) {
98
+ const fulfilled = results.map((r) => r.value);
99
+ if (fulfilled.every(Result.isOk)) {
100
+ return Result.ok(undefined);
101
+ } else {
102
+ const errors: readonly unknown[] = fulfilled
103
+ .filter(Result.isErr)
104
+ .map((r) => r.value);
105
+
106
+ return Result.err(errors);
107
+ }
108
+ } else {
109
+ const errors: readonly unknown[] = results
110
+ .filter((r) => r.status === 'rejected')
111
+ .map((r) => r.reason as unknown);
70
112
 
71
- // Check if any formatting failed
72
- const hasErrors = results.some((result) => result.status === 'rejected');
73
- return hasErrors ? 'err' : 'ok';
113
+ return Result.err(errors);
114
+ }
74
115
  };
75
116
 
76
117
  /**
77
118
  * Format files matching the given glob pattern using Prettier
78
119
  *
79
120
  * @param pathGlob - Glob pattern to match files
80
- * @returns 'ok' if successful, 'err' if any errors occurred
81
121
  */
82
- export const formatFiles = async (
122
+ export const formatFilesGlob = async (
83
123
  pathGlob: string,
84
124
  options?: Readonly<{ silent?: boolean }>,
85
- ): Promise<'ok' | 'err'> => {
125
+ ): Promise<Result<undefined, unknown>> => {
86
126
  const silent = options?.silent ?? false;
87
127
 
88
128
  try {
@@ -97,13 +137,15 @@ export const formatFiles = async (
97
137
  if (!silent) {
98
138
  echo('No files found matching pattern:', pathGlob);
99
139
  }
100
- return 'ok';
140
+ return Result.ok(undefined);
101
141
  }
102
142
 
103
- return await formatFilesList(files, { silent });
143
+ return await formatFiles(files, { silent });
104
144
  } catch (error) {
105
- console.error('Error in formatFiles:', error);
106
- return 'err';
145
+ if (!silent) {
146
+ console.error('Error in formatFiles:', error);
147
+ }
148
+ return Result.err(error);
107
149
  }
108
150
  };
109
151
 
@@ -111,63 +153,72 @@ export const formatFiles = async (
111
153
  * Format only files that have been changed (git status)
112
154
  *
113
155
  * @param options - Options for formatting
114
- * @returns 'ok' if successful, 'err' if any errors occurred
115
156
  */
116
- export const formatUntracked = async (
117
- options?: Readonly<{ silent?: boolean }>,
118
- ): Promise<'ok' | 'err'> => {
119
- const silent = options?.silent ?? false;
120
-
121
- try {
122
- const untrackedFilesResult = await getUntrackedFiles({
123
- silent,
124
- });
157
+ export const formatUncommittedFiles = async (
158
+ options?: Readonly<{
159
+ untracked?: boolean;
160
+ modified?: boolean;
161
+ staged?: boolean;
162
+ silent?: boolean;
163
+ }>,
164
+ ): Promise<
165
+ Result<
166
+ undefined,
167
+ ExecException | Readonly<{ message: string }> | readonly unknown[]
168
+ >
169
+ > => {
170
+ const {
171
+ untracked = true,
172
+ modified = true,
173
+ staged = true,
174
+ silent = false,
175
+ } = options ?? {};
176
+
177
+ const mut_files: string[] = [];
178
+
179
+ if (untracked) {
180
+ const untrackedFilesResult = await getUntrackedFiles({ silent });
125
181
 
126
182
  if (Result.isErr(untrackedFilesResult)) {
127
- console.error('Error getting changed files:', untrackedFilesResult.value);
128
- return 'err';
183
+ if (!silent) {
184
+ console.error(
185
+ 'Error getting changed files:',
186
+ untrackedFilesResult.value,
187
+ );
188
+ }
189
+ return untrackedFilesResult;
129
190
  }
130
191
 
131
- const files = untrackedFilesResult.value;
192
+ mut_files.push(...untrackedFilesResult.value);
193
+ }
194
+
195
+ if (modified) {
196
+ const diffFilesResult = await getModifiedFiles({ silent });
132
197
 
133
- if (files.length === 0) {
198
+ if (Result.isErr(diffFilesResult)) {
134
199
  if (!silent) {
135
- echo('No changed files to format');
200
+ console.error('Error getting changed files:', diffFilesResult.value);
136
201
  }
137
- return 'ok';
202
+ return diffFilesResult;
138
203
  }
139
204
 
140
- if (!silent) {
141
- echo('Formatting changed files:', files);
142
- }
205
+ mut_files.push(...diffFilesResult.value);
206
+ }
143
207
 
144
- // Filter out non-existent files before formatting
145
- const fileExistenceChecks = await Promise.allSettled(
146
- files.map(async (filePath) => {
147
- try {
148
- await fs.readFile(filePath, 'utf8');
149
- return filePath;
150
- } catch {
151
- if (!silent) {
152
- echo(`Skipping non-existent file: ${filePath}`);
153
- }
154
- return undefined;
155
- }
156
- }),
157
- );
208
+ if (staged) {
209
+ const stagedFilesResult = await getStagedFiles({ silent });
158
210
 
159
- const existingFiles = fileExistenceChecks
160
- .filter(
161
- (result): result is PromiseFulfilledResult<string> =>
162
- result.status === 'fulfilled' && result.value !== undefined,
163
- )
164
- .map((result) => result.value);
211
+ if (Result.isErr(stagedFilesResult)) {
212
+ if (!silent) {
213
+ console.error('Error getting changed files:', stagedFilesResult.value);
214
+ }
215
+ return stagedFilesResult;
216
+ }
165
217
 
166
- return await formatFilesList(existingFiles, { silent });
167
- } catch (error) {
168
- console.error('Error in formatUntracked:', error);
169
- return 'err';
218
+ mut_files.push(...stagedFilesResult.value);
170
219
  }
220
+
221
+ return formatFiles(Arr.uniq(mut_files), { silent });
171
222
  };
172
223
 
173
224
  /**
@@ -178,71 +229,103 @@ export const formatUntracked = async (
178
229
  * @param options - Options for formatting
179
230
  * @param options.includeUntracked - Include untracked files in addition to diff
180
231
  * files (default is true)
232
+ * @param options.includeStaged - Include staged files in addition to diff files
233
+ * (default is true)
181
234
  * @param options.silent - Silent mode to suppress command output (default is
182
235
  * false)
183
- * @returns 'ok' if successful, 'err' if any errors occurred
184
236
  */
185
237
  export const formatDiffFrom = async (
186
238
  base: string,
187
- options?: Readonly<{ includeUntracked?: boolean; silent?: boolean }>,
188
- ): Promise<'ok' | 'err'> => {
189
- const silent = options?.silent ?? false;
190
-
191
- try {
192
- // Get files that differ from base branch/commit (excluding deleted files)
193
- const diffFromBaseResult = await getDiffFrom(base, {
194
- silent,
195
- });
196
-
197
- if (Result.isErr(diffFromBaseResult)) {
239
+ options?: Readonly<{
240
+ includeUntracked?: boolean;
241
+ includeModified?: boolean;
242
+ includeStaged?: boolean;
243
+ silent?: boolean;
244
+ }>,
245
+ ): Promise<
246
+ Result<
247
+ undefined,
248
+ | ExecException
249
+ | Readonly<{
250
+ message: string;
251
+ }>
252
+ | readonly unknown[]
253
+ >
254
+ > => {
255
+ // const silent = options?.silent ?? false;
256
+ const {
257
+ silent = false,
258
+ includeUntracked = true,
259
+ includeModified = true,
260
+ includeStaged = true,
261
+ } = options ?? {};
262
+
263
+ // Get files that differ from base branch/commit (excluding deleted files)
264
+ const diffFromBaseResult = await getDiffFrom(base, {
265
+ silent,
266
+ });
267
+
268
+ if (Result.isErr(diffFromBaseResult)) {
269
+ if (!silent) {
198
270
  console.error('Error getting changed files:', diffFromBaseResult.value);
199
- return 'err';
200
271
  }
272
+ return diffFromBaseResult;
273
+ }
201
274
 
202
- const diffFiles = diffFromBaseResult.value;
203
- let mut_allFiles = diffFiles;
204
-
205
- // If includeUntracked is true, also get untracked files
206
- if (options?.includeUntracked ?? true) {
207
- const untrackedFilesResult = await getUntrackedFiles({
208
- silent,
209
- });
210
-
211
- if (Result.isErr(untrackedFilesResult)) {
212
- console.error(
213
- 'Error getting untracked files:',
214
- untrackedFilesResult.value,
215
- );
216
- return 'err';
275
+ const diffFiles = diffFromBaseResult.value;
276
+ const mut_allFiles: string[] = diffFiles.slice();
277
+
278
+ // If includeUntracked is true, also get untracked files
279
+ for (const { type, flag, fn } of [
280
+ { type: 'untracked', flag: includeUntracked, fn: getUntrackedFiles },
281
+ { type: 'modified', flag: includeModified, fn: getModifiedFiles },
282
+ { type: 'staged', flag: includeStaged, fn: getStagedFiles },
283
+ ]) {
284
+ if (flag) {
285
+ // eslint-disable-next-line no-await-in-loop
286
+ const filesResult = await fn({ silent });
287
+
288
+ if (Result.isErr(filesResult)) {
289
+ if (!silent) {
290
+ console.error(`Error getting ${type} files:`, filesResult.value);
291
+ }
292
+ return filesResult;
217
293
  }
218
294
 
219
- const untrackedFiles = untrackedFilesResult.value;
295
+ const files = filesResult.value;
220
296
 
221
297
  // Combine and deduplicate files
222
- mut_allFiles = Arr.uniq([...diffFiles, ...untrackedFiles]);
223
-
224
- if (!silent) {
225
- echo(
226
- `Formatting files that differ from ${base} and untracked files:`,
227
- mut_allFiles,
228
- );
229
- }
230
- } else {
231
- if (!silent) {
232
- echo(`Formatting files that differ from ${base}:`, mut_allFiles);
233
- }
298
+ mut_allFiles.push(...files);
234
299
  }
300
+ }
235
301
 
236
- if (mut_allFiles.length === 0) {
237
- if (!silent) {
238
- echo(`No files to format`);
239
- }
240
- return 'ok';
241
- }
302
+ const allFiles = Arr.uniq(mut_allFiles);
242
303
 
243
- return await formatFilesList(mut_allFiles, { silent });
244
- } catch (error) {
245
- console.error('Error in formatDiffFrom:', error);
246
- return 'err';
304
+ if (!silent) {
305
+ const includedFileTypes = [
306
+ includeUntracked ? 'untracked files' : undefined,
307
+ includeModified ? 'modified files' : undefined,
308
+ includeStaged ? 'staged files' : undefined,
309
+ ].filter(isNotUndefined);
310
+
311
+ const message = [
312
+ `Formatting files that differ from ${base}`,
313
+ includedFileTypes
314
+ .map((s, i) =>
315
+ i !== includedFileTypes.length - 1 ? `, ${s}` : ` and ${s}`,
316
+ )
317
+ .join(''),
318
+ ].join('');
319
+
320
+ echo(`${message}:`, allFiles);
247
321
  }
322
+
323
+ if (allFiles.length === 0) {
324
+ if (!silent) {
325
+ echo('No files to format');
326
+ }
327
+ return Result.ok(undefined);
328
+ }
329
+
330
+ return formatFiles(allFiles, { silent });
248
331
  };