ts-repo-utils 2.1.0 → 2.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.
@@ -5,7 +5,12 @@ import '../node-global.mjs';
5
5
  /**
6
6
  * Get files that have been changed (git status).
7
7
  */
8
- export const getUntrackedFiles = async (): Promise<
8
+ export const getUntrackedFiles = async (
9
+ options?: Readonly<{
10
+ /** @default true */
11
+ excludeDeleted?: boolean;
12
+ }>,
13
+ ): Promise<
9
14
  Result<readonly string[], ExecException | Readonly<{ message: string }>>
10
15
  > => {
11
16
  // Get changed files from git status
@@ -29,7 +34,10 @@ export const getUntrackedFiles = async (): Promise<
29
34
  .filter(
30
35
  (file): file is string =>
31
36
  // Filter out deleted files (status starts with 'D')
32
- file !== undefined && !stdout.includes(`D ${file}`),
37
+ file !== undefined &&
38
+ ((options?.excludeDeleted ?? true)
39
+ ? !stdout.includes(`D ${file}`)
40
+ : true),
33
41
  );
34
42
 
35
43
  return Result.ok(files);
@@ -40,11 +48,17 @@ export const getUntrackedFiles = async (): Promise<
40
48
  */
41
49
  export const getDiffFrom = async (
42
50
  base: string,
51
+ options?: Readonly<{
52
+ /** @default true */
53
+ excludeDeleted?: boolean;
54
+ }>,
43
55
  ): Promise<
44
56
  Result<readonly string[], ExecException | Readonly<{ message: string }>>
45
57
  > => {
46
58
  // Get files that differ from base branch/commit (excluding deleted files)
47
- const result = await $(`git diff --name-only ${base} --diff-filter=d`);
59
+ const result = await $(
60
+ `git diff --name-only ${base} ${(options?.excludeDeleted ?? true) ? '--diff-filter=d' : ''}`,
61
+ );
48
62
 
49
63
  if (Result.isErr(result)) {
50
64
  return result;
@@ -5,6 +5,64 @@ import { Result } from 'ts-data-forge';
5
5
  import '../node-global.mjs';
6
6
  import { getDiffFrom, getUntrackedFiles } from './diff.mjs';
7
7
 
8
+ /**
9
+ * Format a list of files using Prettier
10
+ * @param files - Array of file paths to format
11
+ * @returns 'ok' if successful, 'err' if any errors occurred
12
+ */
13
+ export const formatFilesList = async (
14
+ files: readonly string[],
15
+ ): Promise<'ok' | 'err'> => {
16
+ if (files.length === 0) {
17
+ echo('No files to format');
18
+ return 'ok';
19
+ }
20
+
21
+ echo(`Formatting ${files.length} files...`);
22
+
23
+ // Format each file
24
+ const results = await Promise.allSettled(
25
+ files.map(async (filePath) => {
26
+ try {
27
+ // Read file content
28
+ const content = await readFile(filePath, 'utf8');
29
+
30
+ // Resolve prettier config for this file
31
+ const options = await prettier.resolveConfig(filePath);
32
+
33
+ // Check if file is ignored by prettier
34
+ const fileInfo = await prettier.getFileInfo(filePath, {
35
+ ignorePath: '.prettierignore',
36
+ });
37
+
38
+ if (fileInfo.ignored) {
39
+ echo(`Skipping ignored file: ${filePath}`);
40
+ return;
41
+ }
42
+
43
+ // Format the content
44
+ const formatted = await prettier.format(content, {
45
+ ...options,
46
+ filepath: filePath,
47
+ });
48
+
49
+ // Only write if content changed
50
+ if (formatted !== content) {
51
+ await writeFile(filePath, formatted, 'utf8');
52
+ echo(`Formatted: ${filePath}`);
53
+ }
54
+ } catch (error) {
55
+ console.error(`Error formatting ${filePath}:`, error);
56
+ throw error;
57
+ }
58
+ }),
59
+ );
60
+
61
+ // Check if any formatting failed
62
+ const hasErrors = results.some((result) => result.status === 'rejected');
63
+ return hasErrors ? 'err' : 'ok';
64
+ };
65
+
8
66
  /**
9
67
  * Format files matching the given glob pattern using Prettier
10
68
  * @param pathGlob - Glob pattern to match files
@@ -24,49 +82,7 @@ export const formatFiles = async (pathGlob: string): Promise<'ok' | 'err'> => {
24
82
  return 'ok';
25
83
  }
26
84
 
27
- echo(`Formatting ${files.length} files...`);
28
-
29
- // Format each file
30
- const results = await Promise.allSettled(
31
- files.map(async (filePath) => {
32
- try {
33
- // Read file content
34
- const content = await readFile(filePath, 'utf8');
35
-
36
- // Resolve prettier config for this file
37
- const options = await prettier.resolveConfig(filePath);
38
-
39
- // Check if file is ignored by prettier
40
- const fileInfo = await prettier.getFileInfo(filePath, {
41
- ignorePath: '.prettierignore',
42
- });
43
-
44
- if (fileInfo.ignored) {
45
- echo(`Skipping ignored file: ${filePath}`);
46
- return;
47
- }
48
-
49
- // Format the content
50
- const formatted = await prettier.format(content, {
51
- ...options,
52
- filepath: filePath,
53
- });
54
-
55
- // Only write if content changed
56
- if (formatted !== content) {
57
- await writeFile(filePath, formatted, 'utf8');
58
- echo(`Formatted: ${filePath}`);
59
- }
60
- } catch (error) {
61
- console.error(`Error formatting ${filePath}:`, error);
62
- throw error;
63
- }
64
- }),
65
- );
66
-
67
- // Check if any formatting failed
68
- const hasErrors = results.some((result) => result.status === 'rejected');
69
- return hasErrors ? 'err' : 'ok';
85
+ return await formatFilesList(files);
70
86
  } catch (error) {
71
87
  console.error('Error in formatFiles:', error);
72
88
  return 'err';
@@ -95,53 +111,29 @@ export const formatUntracked = async (): Promise<'ok' | 'err'> => {
95
111
 
96
112
  echo('Formatting changed files:', files);
97
113
 
98
- // Format each changed file
99
- const results = await Promise.allSettled(
114
+ // Filter out non-existent files before formatting
115
+ const fileExistenceChecks = await Promise.allSettled(
100
116
  files.map(async (filePath) => {
101
117
  try {
102
- // Check if file exists and is not deleted
103
- const content = await readFile(filePath, 'utf8').catch(() => null);
104
- if (content === null) {
105
- echo(`Skipping non-existent file: ${filePath}`);
106
- return;
107
- }
108
-
109
- // Resolve prettier config for this file
110
- const options = await prettier.resolveConfig(filePath);
111
-
112
- // Check if file is ignored by prettier
113
- const fileInfo = await prettier.getFileInfo(filePath, {
114
- ignorePath: '.prettierignore',
115
- });
116
-
117
- if (fileInfo.ignored) {
118
- echo(`Skipping ignored file: ${filePath}`);
119
- return;
120
- }
121
-
122
- // Format the content
123
- const formatted = await prettier.format(content, {
124
- ...options,
125
- filepath: filePath,
126
- });
127
-
128
- // Only write if content changed
129
- if (formatted !== content) {
130
- await writeFile(filePath, formatted, 'utf8');
131
- echo(`Formatted: ${filePath}`);
132
- }
133
- } catch (error) {
134
- console.error(`Error formatting ${filePath}:`, error);
135
- throw error;
118
+ await readFile(filePath, 'utf8');
119
+ return filePath;
120
+ } catch {
121
+ echo(`Skipping non-existent file: ${filePath}`);
122
+ return undefined;
136
123
  }
137
124
  }),
138
125
  );
139
126
 
140
- // Check if any formatting failed
141
- const hasErrors = results.some((result) => result.status === 'rejected');
142
- return hasErrors ? 'err' : 'ok';
127
+ const existingFiles = fileExistenceChecks
128
+ .filter(
129
+ (result): result is PromiseFulfilledResult<string> =>
130
+ result.status === 'fulfilled' && result.value !== undefined,
131
+ )
132
+ .map((result) => result.value);
133
+
134
+ return await formatFilesList(existingFiles);
143
135
  } catch (error) {
144
- console.error('Error in formatChanged:', error);
136
+ console.error('Error in formatUntracked:', error);
145
137
  return 'err';
146
138
  }
147
139
  };
@@ -149,9 +141,14 @@ export const formatUntracked = async (): Promise<'ok' | 'err'> => {
149
141
  /**
150
142
  * Format only files that differ from the specified base branch or commit
151
143
  * @param base - Base branch name or commit hash to compare against (defaults to 'main')
144
+ * @param options - Options for formatting
145
+ * @param options.includeUntracked - Include untracked files in addition to diff files (default is true)
152
146
  * @returns 'ok' if successful, 'err' if any errors occurred
153
147
  */
154
- export const formatDiffFrom = async (base: string): Promise<'ok' | 'err'> => {
148
+ export const formatDiffFrom = async (
149
+ base: string,
150
+ options?: Readonly<{ includeUntracked?: boolean }>,
151
+ ): Promise<'ok' | 'err'> => {
155
152
  try {
156
153
  // Get files that differ from base branch/commit (excluding deleted files)
157
154
  const diffFromBaseResult = await getDiffFrom(base);
@@ -161,56 +158,41 @@ export const formatDiffFrom = async (base: string): Promise<'ok' | 'err'> => {
161
158
  return 'err';
162
159
  }
163
160
 
164
- const files = diffFromBaseResult.value;
161
+ const diffFiles = diffFromBaseResult.value;
162
+ let allFiles = diffFiles;
163
+
164
+ // If includeUntracked is true, also get untracked files
165
+ if (options?.includeUntracked ?? true) {
166
+ const untrackedFilesResult = await getUntrackedFiles();
167
+
168
+ if (Result.isErr(untrackedFilesResult)) {
169
+ console.error(
170
+ 'Error getting untracked files:',
171
+ untrackedFilesResult.value,
172
+ );
173
+ return 'err';
174
+ }
175
+
176
+ const untrackedFiles = untrackedFilesResult.value;
177
+
178
+ // Combine and deduplicate files
179
+ const uniqueFiles = new Set([...diffFiles, ...untrackedFiles]);
180
+ allFiles = Array.from(uniqueFiles);
181
+
182
+ echo(
183
+ `Formatting files that differ from ${base} and untracked files:`,
184
+ allFiles,
185
+ );
186
+ } else {
187
+ echo(`Formatting files that differ from ${base}:`, allFiles);
188
+ }
165
189
 
166
- if (files.length === 0) {
167
- echo(`No files differ from ${base}`);
190
+ if (allFiles.length === 0) {
191
+ echo(`No files to format`);
168
192
  return 'ok';
169
193
  }
170
194
 
171
- echo(`Formatting files that differ from ${base}:`, files);
172
-
173
- // Format each file
174
- const results = await Promise.allSettled(
175
- files.map(async (filePath) => {
176
- try {
177
- // Read file content
178
- const content = await readFile(filePath, 'utf8');
179
-
180
- // Resolve prettier config for this file
181
- const options = await prettier.resolveConfig(filePath);
182
-
183
- // Check if file is ignored by prettier
184
- const fileInfo = await prettier.getFileInfo(filePath, {
185
- ignorePath: '.prettierignore',
186
- });
187
-
188
- if (fileInfo.ignored) {
189
- echo(`Skipping ignored file: ${filePath}`);
190
- return;
191
- }
192
-
193
- // Format the content
194
- const formatted = await prettier.format(content, {
195
- ...options,
196
- filepath: filePath,
197
- });
198
-
199
- // Only write if content changed
200
- if (formatted !== content) {
201
- await writeFile(filePath, formatted, 'utf8');
202
- echo(`Formatted: ${filePath}`);
203
- }
204
- } catch (error) {
205
- console.error(`Error formatting ${filePath}:`, error);
206
- throw error;
207
- }
208
- }),
209
- );
210
-
211
- // Check if any formatting failed
212
- const hasErrors = results.some((result) => result.status === 'rejected');
213
- return hasErrors ? 'err' : 'ok';
195
+ return await formatFilesList(allFiles);
214
196
  } catch (error) {
215
197
  console.error('Error in formatDiffFrom:', error);
216
198
  return 'err';
@@ -1,30 +1,35 @@
1
1
  import dedent from 'dedent';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { formatFiles } from './format.mjs';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { Result } from 'ts-data-forge';
5
+ import { getDiffFrom, getUntrackedFiles } from './diff.mjs';
6
+ import { formatDiffFrom, formatFiles, formatFilesList } from './format.mjs';
7
+
8
+ vi.mock('./diff.mjs', () => ({
9
+ getDiffFrom: vi.fn(),
10
+ getUntrackedFiles: vi.fn(),
11
+ }));
5
12
 
6
13
  describe('formatFiles', () => {
7
- const testDir = join(process.cwd(), 'test-format-files');
14
+ const testDir = path.join(process.cwd(), 'test-format-files');
8
15
 
9
16
  // Helper to create a test file with unformatted content
10
17
  const createTestFile = async (
11
18
  filename: string,
12
19
  content: string,
13
20
  ): Promise<string> => {
14
- const filePath = join(testDir, filename);
15
- await writeFile(filePath, content, 'utf8');
21
+ const filePath = path.join(testDir, filename);
22
+ await fs.writeFile(filePath, content, 'utf8');
16
23
  return filePath;
17
24
  };
18
25
 
19
26
  // Helper to read file content
20
- const readTestFile = async (filePath: string): Promise<string> => {
21
- const { readFile } = await import('node:fs/promises');
22
- return readFile(filePath, 'utf8');
23
- };
27
+ const readTestFile = async (filePath: string): Promise<string> =>
28
+ fs.readFile(filePath, 'utf8');
24
29
 
25
30
  test('should format files matching glob pattern', async () => {
26
31
  // Setup test directory
27
- await mkdir(testDir, { recursive: true });
32
+ await fs.mkdir(testDir, { recursive: true });
28
33
 
29
34
  try {
30
35
  // Create test files with unformatted code
@@ -71,11 +76,11 @@ describe('formatFiles', () => {
71
76
  );
72
77
 
73
78
  // Check that non-matching file was not touched
74
- const mdContent = await readTestFile(join(testDir, 'test.md'));
79
+ const mdContent = await readTestFile(path.join(testDir, 'test.md'));
75
80
  expect(mdContent).toBe('# Test\n\nSome spaces');
76
81
  } finally {
77
82
  // Cleanup
78
- await rm(testDir, { recursive: true, force: true });
83
+ await fs.rm(testDir, { recursive: true, force: true });
79
84
  }
80
85
  });
81
86
 
@@ -86,12 +91,12 @@ describe('formatFiles', () => {
86
91
 
87
92
  test('should handle nested directories with glob pattern', async () => {
88
93
  // Setup test directory with nested structure
89
- await mkdir(join(testDir, 'src', 'utils'), { recursive: true });
94
+ await fs.mkdir(path.join(testDir, 'src', 'utils'), { recursive: true });
90
95
 
91
96
  try {
92
97
  // Create nested test file
93
98
  const nestedFile = await createTestFile(
94
- join('src', 'utils', 'helper.ts'),
99
+ path.join('src', 'utils', 'helper.ts'),
95
100
  dedent`
96
101
  export const helper=(x:number)=>{return x*2}
97
102
  `,
@@ -112,7 +117,211 @@ describe('formatFiles', () => {
112
117
  );
113
118
  } finally {
114
119
  // Cleanup
115
- await rm(testDir, { recursive: true, force: true });
120
+ await fs.rm(testDir, { recursive: true, force: true });
121
+ }
122
+ });
123
+ });
124
+
125
+ describe('formatFilesList', () => {
126
+ const testDir = path.join(process.cwd(), 'test-format-files-list');
127
+
128
+ // Helper to create a test file with unformatted content
129
+ const createTestFile = async (
130
+ filename: string,
131
+ content: string,
132
+ ): Promise<string> => {
133
+ const filePath = path.join(testDir, filename);
134
+ await fs.writeFile(filePath, content, 'utf8');
135
+ return filePath;
136
+ };
137
+
138
+ // Helper to read file content
139
+ const readTestFile = async (filePath: string): Promise<string> =>
140
+ fs.readFile(filePath, 'utf8');
141
+
142
+ test('should format a list of files', async () => {
143
+ await fs.mkdir(testDir, { recursive: true });
144
+
145
+ try {
146
+ // Create test files
147
+ const file1 = await createTestFile(
148
+ 'file1.ts',
149
+ dedent`
150
+ const x={a:1,b:2}
151
+ `,
152
+ );
153
+
154
+ const file2 = await createTestFile(
155
+ 'file2.ts',
156
+ dedent`
157
+ function test(){return"hello"}
158
+ `,
159
+ );
160
+
161
+ // Format the files
162
+ const result = await formatFilesList([file1, file2]);
163
+ expect(result).toBe('ok');
164
+
165
+ // Check formatted content
166
+ const content1 = await readTestFile(file1);
167
+ expect(content1).toBe(
168
+ `${dedent`
169
+ const x = { a: 1, b: 2 };
170
+ `}\n`,
171
+ );
172
+
173
+ const content2 = await readTestFile(file2);
174
+ expect(content2).toBe(
175
+ `${dedent`
176
+ function test() {
177
+ return 'hello';
178
+ }
179
+ `}\n`,
180
+ );
181
+ } finally {
182
+ await fs.rm(testDir, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ test('should return ok for empty file list', async () => {
187
+ const result = await formatFilesList([]);
188
+ expect(result).toBe('ok');
189
+ });
190
+ });
191
+
192
+ describe('formatDiffFrom', () => {
193
+ const testDir = path.join(process.cwd(), 'test-format-diff');
194
+
195
+ const createTestFile = async (
196
+ filename: string,
197
+ content: string,
198
+ ): Promise<string> => {
199
+ const filePath = path.join(testDir, filename);
200
+ await fs.writeFile(filePath, content, 'utf8');
201
+ return filePath;
202
+ };
203
+
204
+ const readTestFile = async (filePath: string): Promise<string> =>
205
+ fs.readFile(filePath, 'utf8');
206
+
207
+ beforeEach(() => {
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ test('should format files from diff', async () => {
212
+ await fs.mkdir(testDir, { recursive: true });
213
+
214
+ try {
215
+ const file1 = await createTestFile(
216
+ 'diff1.ts',
217
+ dedent`
218
+ const a=1;const b=2
219
+ `,
220
+ );
221
+
222
+ // Mock getDiffFrom to return our test file
223
+ vi.mocked(getDiffFrom).mockResolvedValue(Result.ok([file1]));
224
+
225
+ vi.mocked(getUntrackedFiles).mockResolvedValue(Result.ok([]));
226
+
227
+ const result = await formatDiffFrom('main');
228
+ expect(result).toBe('ok');
229
+
230
+ // Check file was formatted
231
+ const content = await readTestFile(file1);
232
+ expect(content).toBe(
233
+ `${dedent`
234
+ const a = 1;
235
+ const b = 2;
236
+ `}\n`,
237
+ );
238
+
239
+ expect(getDiffFrom).toHaveBeenCalledWith('main');
240
+ } finally {
241
+ await fs.rm(testDir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ test('should include untracked files when option is set', async () => {
246
+ await fs.mkdir(testDir, { recursive: true });
247
+
248
+ try {
249
+ const diffFile = await createTestFile(
250
+ 'diff.ts',
251
+ dedent`
252
+ const diff=true
253
+ `,
254
+ );
255
+
256
+ const untrackedFile = await createTestFile(
257
+ 'untracked.ts',
258
+ dedent`
259
+ const untracked=true
260
+ `,
261
+ );
262
+
263
+ // Mock both functions
264
+ vi.mocked(getDiffFrom).mockResolvedValue(Result.ok([diffFile]));
265
+ vi.mocked(getUntrackedFiles).mockResolvedValue(
266
+ Result.ok([untrackedFile]),
267
+ );
268
+
269
+ const result = await formatDiffFrom('main', { includeUntracked: true });
270
+ expect(result).toBe('ok');
271
+
272
+ // Check both files were formatted
273
+ const diffContent = await readTestFile(diffFile);
274
+ expect(diffContent).toBe(
275
+ `${dedent`
276
+ const diff = true;
277
+ `}\n`,
278
+ );
279
+
280
+ const untrackedContent = await readTestFile(untrackedFile);
281
+ expect(untrackedContent).toBe(
282
+ `${dedent`
283
+ const untracked = true;
284
+ `}\n`,
285
+ );
286
+
287
+ expect(getDiffFrom).toHaveBeenCalledWith('main');
288
+ expect(getUntrackedFiles).toHaveBeenCalled();
289
+ } finally {
290
+ await fs.rm(testDir, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('should deduplicate files when including untracked', async () => {
295
+ await fs.mkdir(testDir, { recursive: true });
296
+
297
+ try {
298
+ const sharedFile = await createTestFile(
299
+ 'shared.ts',
300
+ dedent`
301
+ const shared={value:1}
302
+ `,
303
+ );
304
+
305
+ // Mock both functions to return the same file
306
+ vi.mocked(getDiffFrom).mockResolvedValue(Result.ok([sharedFile]));
307
+ vi.mocked(getUntrackedFiles).mockResolvedValue(Result.ok([sharedFile]));
308
+
309
+ const result = await formatDiffFrom('main', { includeUntracked: true });
310
+ expect(result).toBe('ok');
311
+
312
+ // Verify both functions were called
313
+ expect(getDiffFrom).toHaveBeenCalledWith('main');
314
+ expect(getUntrackedFiles).toHaveBeenCalled();
315
+
316
+ // Check that the file was formatted (content should change)
317
+ const finalContent = await readTestFile(sharedFile);
318
+ expect(finalContent).toBe(
319
+ `${dedent`
320
+ const shared = { value: 1 };
321
+ `}\n`,
322
+ );
323
+ } finally {
324
+ await fs.rm(testDir, { recursive: true, force: true });
116
325
  }
117
326
  });
118
327
  });
@@ -5,3 +5,4 @@ export * from './diff.mjs';
5
5
  export * from './exec-async.mjs';
6
6
  export * from './format.mjs';
7
7
  export * from './gen-index.mjs';
8
+ export * from './should-run.mjs';
@@ -0,0 +1,33 @@
1
+ import { pipe, Result } from 'ts-data-forge';
2
+ import '../node-global.mjs';
3
+ import { getDiffFrom } from './diff.mjs';
4
+
5
+ export const checkShouldRunTypeChecks = async (): Promise<void> => {
6
+ // paths-ignore:
7
+ // - '.cspell.json'
8
+ // - '**.md'
9
+ // - '**.txt'
10
+ // - 'docs/**'
11
+
12
+ const GITHUB_OUTPUT = process.env['GITHUB_OUTPUT'];
13
+
14
+ const files = await getDiffFrom('origin/main');
15
+
16
+ if (Result.isErr(files)) {
17
+ console.error('Error getting diff:', files.value);
18
+ process.exit(1);
19
+ }
20
+
21
+ const shouldRunTsChecks: boolean = !files.value.every(
22
+ (file) =>
23
+ file === '.cspell.json' ||
24
+ file.startsWith('docs/') ||
25
+ pipe(path.basename(file)).map(
26
+ (filename) => filename.endsWith('.md') || filename.endsWith('.txt'),
27
+ ).value,
28
+ );
29
+
30
+ if (GITHUB_OUTPUT !== undefined) {
31
+ await fs.appendFile(GITHUB_OUTPUT, `should_run=${shouldRunTsChecks}\n`);
32
+ }
33
+ };