@fuzdev/fuz_util 0.42.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.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/array.d.ts +15 -0
- package/dist/array.d.ts.map +1 -0
- package/dist/array.js +25 -0
- package/dist/async.d.ts +62 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +147 -0
- package/dist/colors.d.ts +41 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +106 -0
- package/dist/counter.d.ts +7 -0
- package/dist/counter.d.ts.map +1 -0
- package/dist/counter.js +7 -0
- package/dist/deep_equal.d.ts +18 -0
- package/dist/deep_equal.d.ts.map +1 -0
- package/dist/deep_equal.js +152 -0
- package/dist/dom.d.ts +35 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +95 -0
- package/dist/error.d.ts +15 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +18 -0
- package/dist/fetch.d.ts +81 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +162 -0
- package/dist/fs.d.ts +34 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +73 -0
- package/dist/function.d.ts +27 -0
- package/dist/function.d.ts.map +1 -0
- package/dist/function.js +21 -0
- package/dist/git.d.ts +132 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +288 -0
- package/dist/id.d.ts +18 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +18 -0
- package/dist/iterator.d.ts +5 -0
- package/dist/iterator.d.ts.map +1 -0
- package/dist/iterator.js +9 -0
- package/dist/json.d.ts +30 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +44 -0
- package/dist/library_json.d.ts +42 -0
- package/dist/library_json.d.ts.map +1 -0
- package/dist/library_json.js +76 -0
- package/dist/log.d.ts +188 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +393 -0
- package/dist/map.d.ts +12 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +14 -0
- package/dist/maths.d.ts +85 -0
- package/dist/maths.d.ts.map +1 -0
- package/dist/maths.js +87 -0
- package/dist/object.d.ts +46 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +89 -0
- package/dist/package_json.d.ts +90 -0
- package/dist/package_json.d.ts.map +1 -0
- package/dist/package_json.js +112 -0
- package/dist/path.d.ts +63 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +83 -0
- package/dist/print.d.ts +52 -0
- package/dist/print.d.ts.map +1 -0
- package/dist/print.js +89 -0
- package/dist/process.d.ts +77 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +148 -0
- package/dist/random.d.ts +25 -0
- package/dist/random.d.ts.map +1 -0
- package/dist/random.js +35 -0
- package/dist/random_alea.d.ts +23 -0
- package/dist/random_alea.d.ts.map +1 -0
- package/dist/random_alea.js +95 -0
- package/dist/regexp.d.ts +12 -0
- package/dist/regexp.d.ts.map +1 -0
- package/dist/regexp.js +16 -0
- package/dist/result.d.ts +64 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +48 -0
- package/dist/source_json.d.ts +375 -0
- package/dist/source_json.d.ts.map +1 -0
- package/dist/source_json.js +189 -0
- package/dist/string.d.ts +51 -0
- package/dist/string.d.ts.map +1 -0
- package/dist/string.js +92 -0
- package/dist/throttle.d.ts +26 -0
- package/dist/throttle.d.ts.map +1 -0
- package/dist/throttle.js +53 -0
- package/dist/timings.d.ts +33 -0
- package/dist/timings.d.ts.map +1 -0
- package/dist/timings.js +75 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/url.d.ts +10 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +8 -0
- package/package.json +125 -0
- package/src/lib/array.ts +30 -0
- package/src/lib/async.ts +182 -0
- package/src/lib/colors.ts +132 -0
- package/src/lib/counter.ts +11 -0
- package/src/lib/deep_equal.ts +155 -0
- package/src/lib/dom.ts +108 -0
- package/src/lib/error.ts +22 -0
- package/src/lib/fetch.ts +231 -0
- package/src/lib/fs.ts +128 -0
- package/src/lib/function.ts +32 -0
- package/src/lib/git.ts +390 -0
- package/src/lib/id.ts +30 -0
- package/src/lib/iterator.ts +8 -0
- package/src/lib/json.ts +61 -0
- package/src/lib/library_json.ts +122 -0
- package/src/lib/log.ts +469 -0
- package/src/lib/map.ts +18 -0
- package/src/lib/maths.ts +91 -0
- package/src/lib/object.ts +110 -0
- package/src/lib/package_json.ts +135 -0
- package/src/lib/path.ts +137 -0
- package/src/lib/print.ts +111 -0
- package/src/lib/process.ts +207 -0
- package/src/lib/random.ts +48 -0
- package/src/lib/random_alea.ts +107 -0
- package/src/lib/regexp.ts +17 -0
- package/src/lib/result.ts +67 -0
- package/src/lib/source_json.ts +209 -0
- package/src/lib/string.ts +99 -0
- package/src/lib/throttle.ts +70 -0
- package/src/lib/timings.ts +93 -0
- package/src/lib/types.ts +99 -0
- package/src/lib/url.ts +14 -0
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type {SpawnOptions} from 'node:child_process';
|
|
2
|
+
import {z} from 'zod';
|
|
3
|
+
|
|
4
|
+
import {spawn, spawn_out} from './process.js';
|
|
5
|
+
import type {Flavored} from './types.js';
|
|
6
|
+
import {to_file_path} from './path.js';
|
|
7
|
+
import {fs_exists} from './fs.js';
|
|
8
|
+
|
|
9
|
+
export const GitOrigin = z.string();
|
|
10
|
+
export type GitOrigin = Flavored<string, 'GitOrigin'>;
|
|
11
|
+
|
|
12
|
+
export const GitBranch = z.string();
|
|
13
|
+
export type GitBranch = Flavored<string, 'GitBranch'>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the current git branch name or throws if something goes wrong.
|
|
17
|
+
*/
|
|
18
|
+
export const git_current_branch_name = async (options?: SpawnOptions): Promise<GitBranch> => {
|
|
19
|
+
const {stdout} = await spawn_out('git', ['rev-parse', '--abbrev-ref', 'HEAD'], options);
|
|
20
|
+
if (!stdout) throw Error('git_current_branch_name failed');
|
|
21
|
+
const branch_name = stdout.trim() as GitBranch;
|
|
22
|
+
return branch_name;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns a boolean indicating if the remote git branch exists
|
|
27
|
+
*/
|
|
28
|
+
export const git_remote_branch_exists = async (
|
|
29
|
+
origin: GitOrigin = 'origin' as GitOrigin,
|
|
30
|
+
branch?: GitBranch,
|
|
31
|
+
options?: SpawnOptions,
|
|
32
|
+
): Promise<boolean> => {
|
|
33
|
+
const final_branch = branch ?? (await git_current_branch_name(options));
|
|
34
|
+
if (options?.cwd && !(await fs_exists(to_file_path(options.cwd)))) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const result = await spawn(
|
|
38
|
+
'git',
|
|
39
|
+
['ls-remote', '--exit-code', '--heads', origin, 'refs/heads/' + final_branch],
|
|
40
|
+
options,
|
|
41
|
+
);
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
return true;
|
|
44
|
+
} else if (result.code === 2) {
|
|
45
|
+
return false;
|
|
46
|
+
} else {
|
|
47
|
+
throw Error(
|
|
48
|
+
`git_remote_branch_exists failed for origin '${origin}' and branch '${final_branch}' with code ${result.code}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @returns a boolean indicating if the local git branch exists
|
|
55
|
+
*/
|
|
56
|
+
export const git_local_branch_exists = async (
|
|
57
|
+
branch: GitBranch,
|
|
58
|
+
options?: SpawnOptions,
|
|
59
|
+
): Promise<boolean> => {
|
|
60
|
+
if (options?.cwd && !(await fs_exists(to_file_path(options.cwd)))) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const result = await spawn('git', ['show-ref', '--quiet', 'refs/heads/' + branch], options);
|
|
64
|
+
return result.ok;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Git workspace status flags indicating which types of changes are present.
|
|
69
|
+
*/
|
|
70
|
+
export interface GitWorkspaceStatus {
|
|
71
|
+
unstaged_changes: boolean;
|
|
72
|
+
staged_changes: boolean;
|
|
73
|
+
untracked_files: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parses the output of `git status --porcelain -z` (v1 format) into a status object.
|
|
78
|
+
* This is a pure function that can be tested independently.
|
|
79
|
+
*
|
|
80
|
+
* Format: XY path\0 where:
|
|
81
|
+
* - X = staged status (index)
|
|
82
|
+
* - Y = unstaged status (work tree)
|
|
83
|
+
* - path = file path (unescaped with -z)
|
|
84
|
+
*
|
|
85
|
+
* Supported status codes:
|
|
86
|
+
* - M = modified
|
|
87
|
+
* - A = added
|
|
88
|
+
* - D = deleted
|
|
89
|
+
* - R = renamed
|
|
90
|
+
* - C = copied
|
|
91
|
+
* - T = type changed (regular file, symbolic link or submodule)
|
|
92
|
+
* - U = unmerged
|
|
93
|
+
* - ? = untracked
|
|
94
|
+
* - ! = ignored
|
|
95
|
+
*
|
|
96
|
+
* For renames/copies: XY new\0old\0 (two NUL-separated paths)
|
|
97
|
+
*
|
|
98
|
+
* Note: This implementation treats submodules the same as regular files.
|
|
99
|
+
* Submodule-specific status codes (lowercase m, ?) are interpreted as changes.
|
|
100
|
+
*
|
|
101
|
+
* @param stdout - The raw output from `git status --porcelain -z`
|
|
102
|
+
* @returns status object with flags for unstaged changes, staged changes, and untracked files
|
|
103
|
+
*/
|
|
104
|
+
export const git_parse_workspace_status = (stdout: string | null): GitWorkspaceStatus => {
|
|
105
|
+
// Split on NUL character (\0) for -z format
|
|
106
|
+
// Filter out empty strings (last element after final \0)
|
|
107
|
+
const entries = stdout?.split('\0').filter(Boolean) ?? [];
|
|
108
|
+
|
|
109
|
+
// For rename/copy operations, we need to skip the old filename entry
|
|
110
|
+
// Format: R new\0old\0 or C new\0old\0
|
|
111
|
+
let skipNext = false;
|
|
112
|
+
const lines: Array<string> = [];
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (skipNext) {
|
|
116
|
+
skipNext = false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if this is a rename/copy operation in either position
|
|
121
|
+
// R = renamed in index, RM = renamed in index + modified in work tree
|
|
122
|
+
// R = renamed in work tree (rare but possible with certain configs)
|
|
123
|
+
if (
|
|
124
|
+
entry.length >= 3 &&
|
|
125
|
+
(entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C')
|
|
126
|
+
) {
|
|
127
|
+
skipNext = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push(entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
// Y position (index 1) - any non-space, non-?, non-! means unstaged changes
|
|
135
|
+
// Minimum length is 3: XY followed by at least one space (e.g., "M ")
|
|
136
|
+
unstaged_changes: lines.some(
|
|
137
|
+
(line) => line.length >= 3 && line[1] !== ' ' && line[1] !== '?' && line[1] !== '!',
|
|
138
|
+
),
|
|
139
|
+
// X position (index 0) - any non-space, non-?, non-! means staged changes
|
|
140
|
+
// Minimum length is 3: XY followed by at least one space (e.g., "M ")
|
|
141
|
+
staged_changes: lines.some(
|
|
142
|
+
(line) => line.length >= 3 && line[0] !== ' ' && line[0] !== '?' && line[0] !== '!',
|
|
143
|
+
),
|
|
144
|
+
// ?? prefix means untracked files
|
|
145
|
+
untracked_files: lines.some((line) => line.startsWith('??')),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Checks the git workspace status using a single `git status --porcelain -z` call.
|
|
151
|
+
* The -z format provides more reliable parsing by using NUL separators and avoiding escaping.
|
|
152
|
+
* @returns status object with flags for unstaged changes, staged changes, and untracked files
|
|
153
|
+
*/
|
|
154
|
+
export const git_check_workspace = async (options?: SpawnOptions): Promise<GitWorkspaceStatus> => {
|
|
155
|
+
const {stdout} = await spawn_out('git', ['status', '--porcelain', '-z'], options);
|
|
156
|
+
return git_parse_workspace_status(stdout);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @returns `true` if the workspace has no changes at all
|
|
161
|
+
*/
|
|
162
|
+
export const git_workspace_is_clean = (status: GitWorkspaceStatus): boolean =>
|
|
163
|
+
!status.unstaged_changes && !status.staged_changes && !status.untracked_files;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @returns `true` if the workspace has no unstaged changes and no untracked files (staged changes are OK)
|
|
167
|
+
*/
|
|
168
|
+
export const git_workspace_is_fully_staged = (status: GitWorkspaceStatus): boolean =>
|
|
169
|
+
!status.unstaged_changes && !status.untracked_files;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Converts a workspace status to a human-readable message.
|
|
173
|
+
*/
|
|
174
|
+
export const git_workspace_status_message = (status: GitWorkspaceStatus): string => {
|
|
175
|
+
if (git_workspace_is_clean(status)) return 'workspace is clean';
|
|
176
|
+
const issues: Array<string> = [];
|
|
177
|
+
if (status.unstaged_changes) issues.push('unstaged changes');
|
|
178
|
+
if (status.staged_changes) issues.push('staged but uncommitted changes');
|
|
179
|
+
if (status.untracked_files) issues.push('untracked files');
|
|
180
|
+
return `git has ${issues.join(', ')}`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @returns an error message if the git workspace has any unstaged or uncommitted changes, or `null` if it's clean
|
|
185
|
+
*/
|
|
186
|
+
export const git_check_clean_workspace = async (options?: SpawnOptions): Promise<string | null> => {
|
|
187
|
+
const status = await git_check_workspace(options);
|
|
188
|
+
return git_workspace_is_clean(status) ? null : git_workspace_status_message(status);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @returns an error message if the git workspace has any unstaged changes or untracked files, or `null` if fully staged
|
|
193
|
+
*/
|
|
194
|
+
export const git_check_fully_staged_workspace = async (
|
|
195
|
+
options?: SpawnOptions,
|
|
196
|
+
): Promise<string | null> => {
|
|
197
|
+
const status = await git_check_workspace(options);
|
|
198
|
+
return git_workspace_is_fully_staged(status) ? null : git_workspace_status_message(status);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Calls `git fetch` and throws if anything goes wrong.
|
|
203
|
+
*/
|
|
204
|
+
export const git_fetch = async (
|
|
205
|
+
origin: GitOrigin = 'origin' as GitOrigin,
|
|
206
|
+
branch?: GitBranch,
|
|
207
|
+
options?: SpawnOptions,
|
|
208
|
+
): Promise<void> => {
|
|
209
|
+
const args = ['fetch', origin];
|
|
210
|
+
if (branch) args.push(branch);
|
|
211
|
+
const result = await spawn('git', args, options);
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
throw Error(
|
|
214
|
+
`git_fetch failed for origin '${origin}' and branch '${branch}' with code ${result.code}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Calls `git checkout` and throws if anything goes wrong.
|
|
221
|
+
* @returns the previous branch name, if it changed
|
|
222
|
+
*/
|
|
223
|
+
export const git_checkout = async (
|
|
224
|
+
branch: GitBranch,
|
|
225
|
+
options?: SpawnOptions,
|
|
226
|
+
): Promise<GitBranch | null> => {
|
|
227
|
+
const current_branch = await git_current_branch_name(options);
|
|
228
|
+
if (branch === current_branch) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const result = await spawn('git', ['checkout', branch], options);
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
throw Error(`git_checkout failed for branch '${branch}' with code ${result.code}`);
|
|
234
|
+
}
|
|
235
|
+
return current_branch;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Calls `git pull` and throws if anything goes wrong.
|
|
240
|
+
*/
|
|
241
|
+
export const git_pull = async (
|
|
242
|
+
origin: GitOrigin = 'origin' as GitOrigin,
|
|
243
|
+
branch?: GitBranch,
|
|
244
|
+
options?: SpawnOptions,
|
|
245
|
+
): Promise<void> => {
|
|
246
|
+
const args = ['pull', origin];
|
|
247
|
+
if (branch) args.push(branch);
|
|
248
|
+
const result = await spawn('git', args, options);
|
|
249
|
+
if (!result.ok) {
|
|
250
|
+
throw Error(`git_pull failed for branch '${branch}' with code ${result.code}`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Calls `git push` and throws if anything goes wrong.
|
|
256
|
+
*/
|
|
257
|
+
export const git_push = async (
|
|
258
|
+
origin: GitOrigin,
|
|
259
|
+
branch?: GitBranch,
|
|
260
|
+
options?: SpawnOptions,
|
|
261
|
+
set_upstream = false,
|
|
262
|
+
): Promise<void> => {
|
|
263
|
+
const final_branch = branch ?? (await git_current_branch_name(options));
|
|
264
|
+
const args = ['push', origin, final_branch];
|
|
265
|
+
if (set_upstream) args.push('-u');
|
|
266
|
+
const result = await spawn('git', args, options);
|
|
267
|
+
if (!result.ok) {
|
|
268
|
+
throw Error(`git_push failed for branch '${final_branch}' with code ${result.code}`);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Calls `git push` and throws if anything goes wrong.
|
|
274
|
+
*/
|
|
275
|
+
export const git_push_to_create = async (
|
|
276
|
+
origin: GitOrigin = 'origin' as GitOrigin,
|
|
277
|
+
branch?: GitBranch,
|
|
278
|
+
options?: SpawnOptions,
|
|
279
|
+
): Promise<void> => {
|
|
280
|
+
const final_branch = branch ?? (await git_current_branch_name(options));
|
|
281
|
+
const push_args = ['push'];
|
|
282
|
+
if (await git_remote_branch_exists(origin, final_branch, options)) {
|
|
283
|
+
push_args.push(origin);
|
|
284
|
+
} else {
|
|
285
|
+
push_args.push('-u', origin);
|
|
286
|
+
}
|
|
287
|
+
push_args.push(final_branch);
|
|
288
|
+
const result = await spawn('git', push_args, options);
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
throw Error(`git_push failed for branch '${final_branch}' with code ${result.code}`);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Deletes a branch locally and throws if anything goes wrong.
|
|
296
|
+
*/
|
|
297
|
+
export const git_delete_local_branch = async (
|
|
298
|
+
branch: GitBranch,
|
|
299
|
+
options?: SpawnOptions,
|
|
300
|
+
): Promise<void> => {
|
|
301
|
+
const result = await spawn('git', ['branch', '-D', branch], options);
|
|
302
|
+
if (!result.ok) {
|
|
303
|
+
throw Error(`git_delete_local_branch failed for branch '${branch}' with code ${result.code}`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Deletes a branch remotely and throws if anything goes wrong.
|
|
309
|
+
*/
|
|
310
|
+
export const git_delete_remote_branch = async (
|
|
311
|
+
origin: GitOrigin,
|
|
312
|
+
branch: GitBranch,
|
|
313
|
+
options?: SpawnOptions,
|
|
314
|
+
): Promise<void> => {
|
|
315
|
+
const result = await spawn('git', ['push', origin, ':' + branch], options);
|
|
316
|
+
if (!result.ok) {
|
|
317
|
+
throw Error(`git_delete_remote_branch failed for branch '${branch}' with code ${result.code}`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Resets the `target` branch back to its first commit both locally and remotely.
|
|
323
|
+
*/
|
|
324
|
+
export const git_reset_branch_to_first_commit = async (
|
|
325
|
+
origin: GitOrigin,
|
|
326
|
+
branch: GitBranch,
|
|
327
|
+
options?: SpawnOptions,
|
|
328
|
+
): Promise<void> => {
|
|
329
|
+
const previous_branch = await git_checkout(branch, options);
|
|
330
|
+
const first_commit_hash = await git_current_branch_first_commit_hash(options);
|
|
331
|
+
await spawn('git', ['reset', '--hard', first_commit_hash], options);
|
|
332
|
+
await spawn('git', ['push', origin, branch, '--force'], options);
|
|
333
|
+
if (previous_branch) {
|
|
334
|
+
await git_checkout(previous_branch, options);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Returns the branch's latest commit hash or throws if something goes wrong.
|
|
340
|
+
*/
|
|
341
|
+
export const git_current_commit_hash = async (
|
|
342
|
+
branch?: string,
|
|
343
|
+
options?: SpawnOptions,
|
|
344
|
+
): Promise<string | null> => {
|
|
345
|
+
const final_branch = branch ?? (await git_current_branch_name(options));
|
|
346
|
+
const {stdout} = await spawn_out('git', ['show-ref', '-s', final_branch], options);
|
|
347
|
+
if (!stdout) return null; // TODO hack for CI
|
|
348
|
+
return stdout.split('\n')[0]!.trim();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Returns the hash of the current branch's first commit or throws if something goes wrong.
|
|
353
|
+
*/
|
|
354
|
+
export const git_current_branch_first_commit_hash = async (
|
|
355
|
+
options?: SpawnOptions,
|
|
356
|
+
): Promise<string> => {
|
|
357
|
+
const {stdout} = await spawn_out(
|
|
358
|
+
'git',
|
|
359
|
+
['rev-list', '--max-parents=0', '--abbrev-commit', 'HEAD'],
|
|
360
|
+
options,
|
|
361
|
+
);
|
|
362
|
+
if (!stdout) throw Error('git_current_branch_first_commit_hash failed');
|
|
363
|
+
return stdout.trim();
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Returns the global git config setting for `pull.rebase`.
|
|
368
|
+
*/
|
|
369
|
+
export const git_check_setting_pull_rebase = async (options?: SpawnOptions): Promise<boolean> => {
|
|
370
|
+
const value = await spawn_out('git', ['config', '--global', 'pull.rebase'], options);
|
|
371
|
+
return value.stdout?.trim() === 'true';
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Clones a branch locally to another directory and updates the origin to match the source.
|
|
376
|
+
*/
|
|
377
|
+
export const git_clone_locally = async (
|
|
378
|
+
origin: GitOrigin,
|
|
379
|
+
branch: GitBranch,
|
|
380
|
+
source_dir: string,
|
|
381
|
+
target_dir: string,
|
|
382
|
+
options?: SpawnOptions,
|
|
383
|
+
): Promise<void> => {
|
|
384
|
+
await spawn('git', ['clone', '-b', branch, '--single-branch', source_dir, target_dir], options);
|
|
385
|
+
const origin_url = (
|
|
386
|
+
await spawn_out('git', ['remote', 'get-url', origin], {...options, cwd: source_dir})
|
|
387
|
+
).stdout?.trim();
|
|
388
|
+
if (!origin_url) throw Error('Failed to get the origin url with git in ' + source_dir);
|
|
389
|
+
await spawn('git', ['remote', 'set-url', origin, origin_url], {...options, cwd: target_dir});
|
|
390
|
+
};
|
package/src/lib/id.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {Flavored} from './types.js';
|
|
2
|
+
import {create_counter} from './counter.js';
|
|
3
|
+
|
|
4
|
+
export type Uuid = Flavored<string, 'Uuid'>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loosely validates a UUID string.
|
|
8
|
+
*/
|
|
9
|
+
export const is_uuid = (str: string): str is Uuid => UUID_MATCHER.test(str);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Postgres doesn't support the namespace prefix, so neither does Felt.
|
|
13
|
+
* For more see the UUID RFC - https://tools.ietf.org/html/rfc4122
|
|
14
|
+
* The Ajv validator does support the namespace, hence this custom implementation.
|
|
15
|
+
*/
|
|
16
|
+
export const UUID_MATCHER = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
|
|
17
|
+
|
|
18
|
+
export type ClientIdCreator = () => string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a string id generator function, outputting `${name}_${count}` by default.
|
|
22
|
+
*/
|
|
23
|
+
export const create_client_id_creator = (
|
|
24
|
+
name: string,
|
|
25
|
+
count?: number,
|
|
26
|
+
separator = '_',
|
|
27
|
+
): ClientIdCreator => {
|
|
28
|
+
const counter = create_counter(count);
|
|
29
|
+
return () => `${name}${separator}${counter()}`;
|
|
30
|
+
};
|
package/src/lib/json.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type Json = JsonPrimitive | JsonObject | JsonArray;
|
|
2
|
+
|
|
3
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
4
|
+
|
|
5
|
+
export interface JsonObject extends Record<string, Json> {} // eslint-disable-line @typescript-eslint/no-empty-object-type
|
|
6
|
+
|
|
7
|
+
export interface JsonArray extends Array<Json> {} // eslint-disable-line @typescript-eslint/no-empty-object-type
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Like `typeof json`, but includes arrays. Excludes `'undefined'` because it's not valid JSON.
|
|
11
|
+
*/
|
|
12
|
+
export type JsonType = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the `JsonType` of `value`, which is like `typeof json`
|
|
16
|
+
* but includes `'array'` and omits `'undefined'`.
|
|
17
|
+
*/
|
|
18
|
+
export const json_type_of = (value: Json): JsonType | undefined => {
|
|
19
|
+
const type = typeof value;
|
|
20
|
+
switch (type) {
|
|
21
|
+
case 'string':
|
|
22
|
+
case 'number':
|
|
23
|
+
case 'boolean':
|
|
24
|
+
return type;
|
|
25
|
+
case 'object': {
|
|
26
|
+
return value === null ? 'null' : Array.isArray(value) ? 'array' : 'object';
|
|
27
|
+
}
|
|
28
|
+
default: {
|
|
29
|
+
// "undefined" | "function" | "bigint" | "symbol"
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Embeds `data` as a JSON string, escaping single quotes.
|
|
37
|
+
* Useful for optimizing JSON in JS because it parses faster.
|
|
38
|
+
*/
|
|
39
|
+
export const json_embed = <T>(data: T, stringify: (data: T) => string = JSON.stringify): string =>
|
|
40
|
+
`JSON.parse('${stringify(data).replaceAll("'", "\\'").replaceAll('\n', '\\\n')}')`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serializes a value to JSON with deterministic key ordering.
|
|
44
|
+
* Recursively sorts object keys alphabetically for consistent hashing.
|
|
45
|
+
* Arrays and primitives are serialized as-is.
|
|
46
|
+
*
|
|
47
|
+
* @param value Any JSON-serializable value
|
|
48
|
+
* @returns Deterministic JSON string representation
|
|
49
|
+
*/
|
|
50
|
+
export const json_stringify_deterministic = (value: unknown): string =>
|
|
51
|
+
JSON.stringify(value, (_key, val) => {
|
|
52
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
53
|
+
const sorted: Record<string, unknown> = {};
|
|
54
|
+
const keys = Object.keys(val).sort();
|
|
55
|
+
for (const k of keys) {
|
|
56
|
+
sorted[k] = val[k];
|
|
57
|
+
}
|
|
58
|
+
return sorted;
|
|
59
|
+
}
|
|
60
|
+
return val;
|
|
61
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library metadata combining package.json with analyzed source.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {ensure_end, strip_end, strip_start} from './string.js';
|
|
6
|
+
import type {PackageJson} from './package_json.js';
|
|
7
|
+
import type {SourceJson} from './source_json.js';
|
|
8
|
+
import type {Url} from './url.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A library's package.json and source metadata with computed properties.
|
|
12
|
+
*/
|
|
13
|
+
export interface LibraryJson {
|
|
14
|
+
package_json: PackageJson;
|
|
15
|
+
source_json: SourceJson;
|
|
16
|
+
/** Package name, e.g. `@ryanatkn/fuz`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Name without scope, e.g. `fuz`. */
|
|
19
|
+
repo_name: string;
|
|
20
|
+
/** GitHub repo URL, e.g. `https://github.com/ryanatkn/fuz`. */
|
|
21
|
+
repo_url: Url;
|
|
22
|
+
/** GitHub user/org, e.g. `ryanatkn`. */
|
|
23
|
+
owner_name: string | null;
|
|
24
|
+
homepage_url: Url | null;
|
|
25
|
+
/** Logo URL, falls back to `favicon.png`. */
|
|
26
|
+
logo_url: Url | null;
|
|
27
|
+
logo_alt: string;
|
|
28
|
+
npm_url: Url | null;
|
|
29
|
+
changelog_url: Url | null;
|
|
30
|
+
/** True if has exports and version is not `0.0.1`. */
|
|
31
|
+
published: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a `LibraryJson` with computed properties from package.json and source metadata.
|
|
36
|
+
*/
|
|
37
|
+
export const library_json_parse = (
|
|
38
|
+
package_json: PackageJson,
|
|
39
|
+
source_json: SourceJson,
|
|
40
|
+
): LibraryJson => {
|
|
41
|
+
const {name} = package_json;
|
|
42
|
+
|
|
43
|
+
// TODO hacky
|
|
44
|
+
const parse_repo = (r: string | null | undefined) => {
|
|
45
|
+
if (!r) return null;
|
|
46
|
+
return strip_end(strip_start(strip_end(r, '.git'), 'git+'), '/');
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const repo_url = parse_repo(
|
|
50
|
+
package_json.repository
|
|
51
|
+
? typeof package_json.repository === 'string'
|
|
52
|
+
? package_json.repository
|
|
53
|
+
: package_json.repository.url
|
|
54
|
+
: null,
|
|
55
|
+
);
|
|
56
|
+
if (!repo_url) {
|
|
57
|
+
throw Error('failed to parse library_json - `repo_url` is required in package_json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const homepage_url = package_json.homepage ?? null;
|
|
61
|
+
|
|
62
|
+
const published =
|
|
63
|
+
!package_json.private && !!package_json.exports && package_json.version !== '0.0.1';
|
|
64
|
+
|
|
65
|
+
// TODO generic registries
|
|
66
|
+
const npm_url = published ? 'https://www.npmjs.com/package/' + package_json.name : null;
|
|
67
|
+
|
|
68
|
+
const changelog_url = published && repo_url ? repo_url + '/blob/main/CHANGELOG.md' : null;
|
|
69
|
+
|
|
70
|
+
const repo_name = library_repo_name_parse(name);
|
|
71
|
+
|
|
72
|
+
const owner_name = repo_url ? strip_start(repo_url, 'https://github.com/').split('/')[0]! : null;
|
|
73
|
+
|
|
74
|
+
const logo_url = homepage_url
|
|
75
|
+
? ensure_end(homepage_url, '/') +
|
|
76
|
+
(package_json.logo ? strip_start(package_json.logo, '/') : 'favicon.png')
|
|
77
|
+
: null;
|
|
78
|
+
|
|
79
|
+
const logo_alt = package_json.logo_alt ?? `logo for ${repo_name}`;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
package_json,
|
|
83
|
+
source_json,
|
|
84
|
+
name,
|
|
85
|
+
repo_name,
|
|
86
|
+
repo_url,
|
|
87
|
+
owner_name,
|
|
88
|
+
homepage_url,
|
|
89
|
+
logo_url,
|
|
90
|
+
logo_alt,
|
|
91
|
+
npm_url,
|
|
92
|
+
changelog_url,
|
|
93
|
+
published,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extracts repo name from a package name, e.g. `@ryanatkn/fuz` → `fuz`.
|
|
99
|
+
*/
|
|
100
|
+
export const library_repo_name_parse = (name: string): string => {
|
|
101
|
+
if (name[0] === '@') {
|
|
102
|
+
const parts = name.split('/');
|
|
103
|
+
if (parts.length < 2) {
|
|
104
|
+
throw new Error(`invalid scoped package name: "${name}" (expected format: @org/package)`);
|
|
105
|
+
}
|
|
106
|
+
return parts[1]!;
|
|
107
|
+
}
|
|
108
|
+
return name;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extracts GitHub org URL from a library, e.g. `https://github.com/ryanatkn`.
|
|
113
|
+
*/
|
|
114
|
+
export const library_org_url_parse = (library: LibraryJson): string | null => {
|
|
115
|
+
const {repo_name, repo_url} = library;
|
|
116
|
+
if (!repo_url) return null;
|
|
117
|
+
const suffix = '/' + repo_name;
|
|
118
|
+
if (repo_url.endsWith(suffix)) {
|
|
119
|
+
return strip_end(repo_url, suffix);
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
};
|