git-push-time 1.1.2 → 1.1.4
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/.github/workflows/npm-publish.yml +59 -32
- package/README.md +110 -112
- package/bin/index.js +321 -339
- package/lib/commands/edit-time.js +141 -141
- package/lib/commands/fix.js +113 -113
- package/lib/commands/github.js +429 -445
- package/lib/commands/pull.js +90 -90
- package/lib/commands/push.js +720 -720
- package/lib/config.js +220 -220
- package/lib/fix.js +286 -286
- package/lib/git.js +525 -516
- package/lib/github.js +322 -322
- package/lib/prompt-helpers.js +19 -20
- package/lib/secrets.js +92 -92
- package/lib/ui.js +1057 -1063
- package/lib/utils.js +120 -120
- package/package.json +36 -36
package/lib/git.js
CHANGED
|
@@ -1,516 +1,525 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
* @returns {boolean}
|
|
8
|
-
*/
|
|
9
|
-
export function checkGitInstalled() {
|
|
10
|
-
try {
|
|
11
|
-
const r = spawnSync('git', ['--version'], { stdio: 'ignore' });
|
|
12
|
-
return r.status === 0;
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
* @param {string} cwd
|
|
21
|
-
* @returns {boolean}
|
|
22
|
-
*/
|
|
23
|
-
export function isGitRepo(cwd = process.cwd()) {
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8' });
|
|
27
|
-
return r.status === 0 && (r.stdout || '').trim() === 'true';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
* @param {string} cwd
|
|
33
|
-
*/
|
|
34
|
-
export function initGitRepo(cwd = process.cwd()) {
|
|
35
|
-
const r = spawnSync('git', ['init'], { cwd, encoding: 'utf-8' });
|
|
36
|
-
if (r.status !== 0) {
|
|
37
|
-
throw new Error(String(r.stderr || r.stdout || 'git init
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* @param {string} cwd
|
|
45
|
-
* @returns {string|null}
|
|
46
|
-
*/
|
|
47
|
-
export function getGitTopLevel(cwd = process.cwd()) {
|
|
48
|
-
//
|
|
49
|
-
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' });
|
|
50
|
-
if (r.status !== 0) return null;
|
|
51
|
-
const out = (r.stdout || '').trim();
|
|
52
|
-
return out ? path.normalize(out) : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @param {string} cwd
|
|
59
|
-
* @returns {{ name: string, fullPath: string }[]}
|
|
60
|
-
*/
|
|
61
|
-
export function listSubdirectories(cwd = process.cwd()) {
|
|
62
|
-
try {
|
|
63
|
-
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
64
|
-
return entries
|
|
65
|
-
.filter((e) => {
|
|
66
|
-
if (!e.isDirectory()) return false;
|
|
67
|
-
if (e.name === '.git') return false;
|
|
68
|
-
//
|
|
69
|
-
try {
|
|
70
|
-
fs.statSync(path.join(cwd, e.name));
|
|
71
|
-
return true;
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
})
|
|
76
|
-
.map((e) => ({ name: e.name, fullPath: path.join(cwd, e.name) }))
|
|
77
|
-
.sort((a, b) => {
|
|
78
|
-
const aHidden = a.name.startsWith('.') ? 1 : 0;
|
|
79
|
-
const bHidden = b.name.startsWith('.') ? 1 : 0;
|
|
80
|
-
if (aHidden !== bHidden) return aHidden - bHidden;
|
|
81
|
-
return a.name.localeCompare(b.name);
|
|
82
|
-
});
|
|
83
|
-
} catch {
|
|
84
|
-
return [];
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
* @param {string} inputPath
|
|
91
|
-
* @param {string} base
|
|
92
|
-
* @returns {{ ok: boolean, fullPath?: string, error?: string }}
|
|
93
|
-
*/
|
|
94
|
-
export function resolveTargetDir(inputPath, base = process.cwd()) {
|
|
95
|
-
if (!inputPath || !String(inputPath).trim()) {
|
|
96
|
-
return { ok: false, error: '
|
|
97
|
-
}
|
|
98
|
-
const fullPath = path.resolve(base, String(inputPath).trim());
|
|
99
|
-
try {
|
|
100
|
-
const stat = fs.statSync(fullPath);
|
|
101
|
-
if (!stat.isDirectory()) return { ok: false, error: `'${inputPath}'
|
|
102
|
-
} catch {
|
|
103
|
-
return { ok: false, error: `
|
|
104
|
-
}
|
|
105
|
-
return { ok: true, fullPath };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* @param {string} cwd
|
|
113
|
-
* @returns {string}
|
|
114
|
-
*/
|
|
115
|
-
export function getCurrentBranch(cwd = process.cwd()) {
|
|
116
|
-
return getCurrentBranchStrict(cwd) || 'main';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Return the current local change count, origin URL, and last commit for the dashboard. */
|
|
120
|
-
export function getDashboardGitStatus(cwd = process.cwd()) {
|
|
121
|
-
const status = spawnSync('git', ['status', '--short'], { cwd, encoding: 'utf-8' });
|
|
122
|
-
const remote = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf-8' });
|
|
123
|
-
const log = spawnSync('git', ['log', '-1', '--format=%h|%s|%cr'], { cwd, encoding: 'utf-8' });
|
|
124
|
-
const changes = status.status === 0
|
|
125
|
-
? (status.stdout || '').split(/\r?\n/).filter(Boolean).length
|
|
126
|
-
: 0;
|
|
127
|
-
|
|
128
|
-
let lastCommit = null;
|
|
129
|
-
if (log.status === 0 && (log.stdout || '').trim()) {
|
|
130
|
-
const [hash, msg, rel] = (log.stdout || '').trim().split('|');
|
|
131
|
-
if (hash) {
|
|
132
|
-
lastCommit = { hash, message: msg || '', relativeTime: rel || '' };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
changes,
|
|
138
|
-
remoteUrl: remote.status === 0 ? (remote.stdout || '').trim() : null,
|
|
139
|
-
lastCommit,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
* @param {string} cwd
|
|
146
|
-
* @returns {string|null}
|
|
147
|
-
*/
|
|
148
|
-
export function getCurrentBranchStrict(cwd = process.cwd()) {
|
|
149
|
-
//
|
|
150
|
-
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
|
|
151
|
-
if (r.status !== 0) return null;
|
|
152
|
-
return (r.stdout || '').trim() || null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Validate
|
|
157
|
-
* @param {string} name
|
|
158
|
-
* @returns {boolean}
|
|
159
|
-
*/
|
|
160
|
-
export function isValidBranchName(name) {
|
|
161
|
-
if (!name || typeof name !== 'string') return false;
|
|
162
|
-
const t = name.trim();
|
|
163
|
-
if (!t || t.length > 255) return false;
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
if (/[\s~^:?*\[\\]@{]/.test(t)) return false;
|
|
167
|
-
if (t.includes('..') || t.startsWith('/') || t.endsWith('/') || t.endsWith('.lock')) return false;
|
|
168
|
-
if (t === 'HEAD') return false;
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
*
|
|
174
|
-
* @param {string} branchName
|
|
175
|
-
* @param {string} cwd
|
|
176
|
-
*/
|
|
177
|
-
export function setBranchName(branchName = 'main', cwd = process.cwd()) {
|
|
178
|
-
const clean = String(branchName || 'main').trim() || 'main';
|
|
179
|
-
if (!isValidBranchName(clean)) {
|
|
180
|
-
throw new Error(`
|
|
181
|
-
}
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
files
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check whether Git is installed on this machine.
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function checkGitInstalled() {
|
|
10
|
+
try {
|
|
11
|
+
const r = spawnSync('git', ['--version'], { stdio: 'ignore' });
|
|
12
|
+
return r.status === 0;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether the current directory is inside a Git repository.
|
|
20
|
+
* @param {string} cwd
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isGitRepo(cwd = process.cwd()) {
|
|
24
|
+
// Use spawnSync with an argument array instead of a shell to avoid Windows errors
|
|
25
|
+
// when the folder contains a file named git.* (e.g. git.js) that cmd.exe may resolve via PATHEXT.
|
|
26
|
+
const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8' });
|
|
27
|
+
return r.status === 0 && (r.stdout || '').trim() === 'true';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialize a Git repository in the current directory.
|
|
32
|
+
* @param {string} cwd
|
|
33
|
+
*/
|
|
34
|
+
export function initGitRepo(cwd = process.cwd()) {
|
|
35
|
+
const r = spawnSync('git', ['init'], { cwd, encoding: 'utf-8' });
|
|
36
|
+
if (r.status !== 0) {
|
|
37
|
+
throw new Error(String(r.stderr || r.stdout || 'git init failed').trim());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the top-level directory of the Git repository containing `cwd`.
|
|
43
|
+
* Returns null when `cwd` is not inside a repository.
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @returns {string|null}
|
|
46
|
+
*/
|
|
47
|
+
export function getGitTopLevel(cwd = process.cwd()) {
|
|
48
|
+
// Use spawnSync without a shell; see the note in isGitRepo.
|
|
49
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' });
|
|
50
|
+
if (r.status !== 0) return null;
|
|
51
|
+
const out = (r.stdout || '').trim();
|
|
52
|
+
return out ? path.normalize(out) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* List direct child directories of `cwd` for the user to choose as a push source.
|
|
57
|
+
* Skip .git. Sort regular folders before hidden folders.
|
|
58
|
+
* @param {string} cwd
|
|
59
|
+
* @returns {{ name: string, fullPath: string }[]}
|
|
60
|
+
*/
|
|
61
|
+
export function listSubdirectories(cwd = process.cwd()) {
|
|
62
|
+
try {
|
|
63
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
64
|
+
return entries
|
|
65
|
+
.filter((e) => {
|
|
66
|
+
if (!e.isDirectory()) return false;
|
|
67
|
+
if (e.name === '.git') return false;
|
|
68
|
+
// Skip broken symlinks.
|
|
69
|
+
try {
|
|
70
|
+
fs.statSync(path.join(cwd, e.name));
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.map((e) => ({ name: e.name, fullPath: path.join(cwd, e.name) }))
|
|
77
|
+
.sort((a, b) => {
|
|
78
|
+
const aHidden = a.name.startsWith('.') ? 1 : 0;
|
|
79
|
+
const bHidden = b.name.startsWith('.') ? 1 : 0;
|
|
80
|
+
if (aHidden !== bHidden) return aHidden - bHidden;
|
|
81
|
+
return a.name.localeCompare(b.name);
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize a user-provided source folder path.
|
|
90
|
+
* @param {string} inputPath Absolute or relative path
|
|
91
|
+
* @param {string} base Base directory for resolving relative paths
|
|
92
|
+
* @returns {{ ok: boolean, fullPath?: string, error?: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveTargetDir(inputPath, base = process.cwd()) {
|
|
95
|
+
if (!inputPath || !String(inputPath).trim()) {
|
|
96
|
+
return { ok: false, error: 'Path is empty.' };
|
|
97
|
+
}
|
|
98
|
+
const fullPath = path.resolve(base, String(inputPath).trim());
|
|
99
|
+
try {
|
|
100
|
+
const stat = fs.statSync(fullPath);
|
|
101
|
+
if (!stat.isDirectory()) return { ok: false, error: `'${inputPath}' is not a directory.` };
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, error: `Directory not found: '${inputPath}'.` };
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, fullPath };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the current branch name, defaulting to main for display.
|
|
110
|
+
* Returns 'main' for detached HEAD to preserve legacy UI behavior.
|
|
111
|
+
* Use getCurrentBranchStrict() when detached HEAD must be detected accurately.
|
|
112
|
+
* @param {string} cwd
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function getCurrentBranch(cwd = process.cwd()) {
|
|
116
|
+
return getCurrentBranchStrict(cwd) || 'main';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Return the current local change count, origin URL, and last commit for the dashboard. */
|
|
120
|
+
export function getDashboardGitStatus(cwd = process.cwd()) {
|
|
121
|
+
const status = spawnSync('git', ['status', '--short'], { cwd, encoding: 'utf-8' });
|
|
122
|
+
const remote = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf-8' });
|
|
123
|
+
const log = spawnSync('git', ['log', '-1', '--format=%h|%s|%cr'], { cwd, encoding: 'utf-8' });
|
|
124
|
+
const changes = status.status === 0
|
|
125
|
+
? (status.stdout || '').split(/\r?\n/).filter(Boolean).length
|
|
126
|
+
: 0;
|
|
127
|
+
|
|
128
|
+
let lastCommit = null;
|
|
129
|
+
if (log.status === 0 && (log.stdout || '').trim()) {
|
|
130
|
+
const [hash, msg, rel] = (log.stdout || '').trim().split('|');
|
|
131
|
+
if (hash) {
|
|
132
|
+
lastCommit = { hash, message: msg || '', relativeTime: rel || '' };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
changes,
|
|
138
|
+
remoteUrl: remote.status === 0 ? (remote.stdout || '').trim() : null,
|
|
139
|
+
lastCommit,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the current branch name, returning null for detached HEAD or errors.
|
|
145
|
+
* @param {string} cwd
|
|
146
|
+
* @returns {string|null}
|
|
147
|
+
*/
|
|
148
|
+
export function getCurrentBranchStrict(cwd = process.cwd()) {
|
|
149
|
+
// Use spawnSync without a shell; see the note in isGitRepo.
|
|
150
|
+
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
|
|
151
|
+
if (r.status !== 0) return null;
|
|
152
|
+
return (r.stdout || '').trim() || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate a branch name to prevent injection.
|
|
157
|
+
* @param {string} name
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function isValidBranchName(name) {
|
|
161
|
+
if (!name || typeof name !== 'string') return false;
|
|
162
|
+
const t = name.trim();
|
|
163
|
+
if (!t || t.length > 255) return false;
|
|
164
|
+
// Reject control characters, surrounding spaces, and Git's reserved characters: ~ ^ : ? * [ \ ..
|
|
165
|
+
// For simplicity, allow letters, numbers, / - _ and . only.
|
|
166
|
+
if (/[\s~^:?*\[\\]@{]/.test(t)) return false;
|
|
167
|
+
if (t.includes('..') || t.startsWith('/') || t.endsWith('/') || t.endsWith('.lock')) return false;
|
|
168
|
+
if (t === 'HEAD') return false;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Set the primary branch name (for example, git branch -M main).
|
|
174
|
+
* @param {string} branchName
|
|
175
|
+
* @param {string} cwd
|
|
176
|
+
*/
|
|
177
|
+
export function setBranchName(branchName = 'main', cwd = process.cwd()) {
|
|
178
|
+
const clean = String(branchName || 'main').trim() || 'main';
|
|
179
|
+
if (!isValidBranchName(clean)) {
|
|
180
|
+
throw new Error(`Invalid branch name: '${branchName}'`);
|
|
181
|
+
}
|
|
182
|
+
const current = getCurrentBranchStrict(cwd);
|
|
183
|
+
if (current === clean) return;
|
|
184
|
+
|
|
185
|
+
// `-M` force-renames and can replace an existing local branch ref. Refuse
|
|
186
|
+
// that case so choosing a branch never silently discards another branch.
|
|
187
|
+
const existing = spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${clean}`], {
|
|
188
|
+
cwd, stdio: 'ignore'
|
|
189
|
+
});
|
|
190
|
+
if (existing.status === 0) {
|
|
191
|
+
throw new Error(`Local branch '${clean}' already exists. Choose another name or check out that branch first.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const r = spawnSync('git', ['branch', '-M', clean], { cwd, encoding: 'utf-8' });
|
|
195
|
+
if (r.status !== 0) {
|
|
196
|
+
const msg = String(r.stderr || r.stdout || 'Could not rename branch.').trim();
|
|
197
|
+
throw new Error(msg);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Configure the `origin` remote for a Git repository.
|
|
203
|
+
* @param {string} repoUrl
|
|
204
|
+
* @param {string} cwd
|
|
205
|
+
*/
|
|
206
|
+
export function setGitRemote(repoUrl, cwd = process.cwd()) {
|
|
207
|
+
if (!repoUrl || typeof repoUrl !== 'string' || !repoUrl.trim()) {
|
|
208
|
+
throw new Error('Repository URL is empty.');
|
|
209
|
+
}
|
|
210
|
+
const url = repoUrl.trim();
|
|
211
|
+
try {
|
|
212
|
+
const rList = spawnSync('git', ['remote'], { cwd, encoding: 'utf-8' });
|
|
213
|
+
const remotes = (rList.stdout || '').split(/\r?\n/).map((s) => s.trim());
|
|
214
|
+
let r;
|
|
215
|
+
if (remotes.includes('origin')) {
|
|
216
|
+
r = spawnSync('git', ['remote', 'set-url', 'origin', url], { cwd, encoding: 'utf-8' });
|
|
217
|
+
} else {
|
|
218
|
+
r = spawnSync('git', ['remote', 'add', 'origin', url], { cwd, encoding: 'utf-8' });
|
|
219
|
+
}
|
|
220
|
+
if (r.status !== 0) {
|
|
221
|
+
throw new Error(String(r.stderr || r.stdout || 'git remote failed').trim());
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new Error(`Could not configure Git remote URL: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Stage all files in the directory.
|
|
230
|
+
* @param {string} cwd
|
|
231
|
+
*/
|
|
232
|
+
export function stageAllFiles(cwd = process.cwd()) {
|
|
233
|
+
const r = spawnSync('git', ['add', '.'], { cwd, encoding: 'utf-8' });
|
|
234
|
+
if (r.status !== 0) {
|
|
235
|
+
throw new Error(String(r.stderr || r.stdout || 'git add failed').trim());
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check for staged or unstaged changes that have not been committed.
|
|
241
|
+
* @param {string} cwd
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
export function hasChangesToCommit(cwd = process.cwd()) {
|
|
245
|
+
try {
|
|
246
|
+
const r = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
|
|
247
|
+
if (r.status !== 0) return true;
|
|
248
|
+
return (r.stdout || '').trim().length > 0;
|
|
249
|
+
} catch {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create a Git commit with a custom timestamp using GIT_AUTHOR_DATE and GIT_COMMITTER_DATE.
|
|
256
|
+
* @param {string} message
|
|
257
|
+
* @param {string} dateFormatted ISO or Git date format
|
|
258
|
+
* @param {string} cwd
|
|
259
|
+
*/
|
|
260
|
+
export function commitWithCustomDate(message, dateFormatted, cwd = process.cwd()) {
|
|
261
|
+
const env = {
|
|
262
|
+
...process.env,
|
|
263
|
+
GIT_AUTHOR_DATE: dateFormatted,
|
|
264
|
+
GIT_COMMITTER_DATE: dateFormatted
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const result = spawnSync('git', ['commit', '-m', message], {
|
|
268
|
+
cwd,
|
|
269
|
+
env,
|
|
270
|
+
encoding: 'utf-8'
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (result.status !== 0) {
|
|
274
|
+
const stderr = result.stderr || result.stdout || 'Unknown error while committing';
|
|
275
|
+
throw new Error(`Git commit failed: ${stderr.trim()}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Push code to a Git remote repository.
|
|
281
|
+
* @param {string} branch Branch name (e.g. main)
|
|
282
|
+
* @param {boolean} force Whether to use --force
|
|
283
|
+
* @param {string} cwd
|
|
284
|
+
*/
|
|
285
|
+
export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()) {
|
|
286
|
+
const args = ['push', '-u', 'origin', branch];
|
|
287
|
+
if (force) {
|
|
288
|
+
args.push('--force');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const result = spawnSync('git', args, {
|
|
292
|
+
cwd,
|
|
293
|
+
encoding: 'utf-8',
|
|
294
|
+
stdio: 'pipe'
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (result.status !== 0) {
|
|
298
|
+
const raw = (result.stderr || result.stdout || 'Unknown error while running git push').trim();
|
|
299
|
+
const parsed = parsePushError(raw);
|
|
300
|
+
const err = new Error(raw);
|
|
301
|
+
err.code = parsed.code;
|
|
302
|
+
err.details = parsed;
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function pullFromRemote(remote = 'origin', branch = null, cwd = process.cwd(), { rebase = false } = {}) {
|
|
308
|
+
const targetBranch = branch || getCurrentBranchStrict(cwd);
|
|
309
|
+
if (!targetBranch) {
|
|
310
|
+
throw new Error('HEAD is detached, so the branch to pull cannot be determined.');
|
|
311
|
+
}
|
|
312
|
+
const strategy = rebase ? '--rebase' : '--ff-only';
|
|
313
|
+
const result = spawnSync('git', ['pull', strategy, remote, targetBranch], { cwd, encoding: 'utf-8', stdio: 'pipe' });
|
|
314
|
+
if (result.status !== 0) {
|
|
315
|
+
throw new Error((result.stderr || result.stdout || `Git pull ${strategy} failed`).trim());
|
|
316
|
+
}
|
|
317
|
+
return (result.stdout || '').trim();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getRecentCommits(limit = 30, cwd = process.cwd()) {
|
|
321
|
+
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
|
322
|
+
// Pass arguments as an array to avoid shell escaping issues on Windows (%, ^, {}).
|
|
323
|
+
// --date=iso includes the time when displaying commits.
|
|
324
|
+
const result = spawnSync('git', ['log', `--max-count=${safeLimit}`, '--format=%H%x09%h%x09%ad%x09%s', '--date=iso'], {
|
|
325
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe']
|
|
326
|
+
});
|
|
327
|
+
if (result.status !== 0) {
|
|
328
|
+
// Return an empty list for repositories without commits so the caller can show a friendly error.
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const output = (result.stdout || '').trim();
|
|
332
|
+
if (!output) return [];
|
|
333
|
+
return output.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
334
|
+
const [hash, shortHash, date, ...subject] = line.split('\t');
|
|
335
|
+
return { hash, shortHash, date, subject: subject.join('\t') };
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Edit the author and committer dates of a commit.
|
|
341
|
+
* - Requires a clean working tree.
|
|
342
|
+
* - Requires a branch checkout (not detached HEAD).
|
|
343
|
+
* - The commit must be in the current branch history.
|
|
344
|
+
* - Rewrites the selected commit and all of its descendants.
|
|
345
|
+
* - Preserves the original history in a backup branch: backup/before-date-edit-*
|
|
346
|
+
*/
|
|
347
|
+
export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()) {
|
|
348
|
+
// 1. Require a clean working tree.
|
|
349
|
+
const statusRes = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
|
|
350
|
+
if (statusRes.status !== 0) throw new Error('Could not read working tree status.');
|
|
351
|
+
if ((statusRes.stdout || '').trim()) throw new Error('Commit or stash your changes before editing a commit timestamp.');
|
|
352
|
+
|
|
353
|
+
// 2. Require a normal branch checkout.
|
|
354
|
+
const branchRes = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
|
|
355
|
+
const branch = (branchRes.stdout || '').trim();
|
|
356
|
+
if (!branch) throw new Error('Cannot edit commits while HEAD is detached.');
|
|
357
|
+
|
|
358
|
+
const headRes = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' });
|
|
359
|
+
if (headRes.status !== 0) throw new Error('Could not determine the current branch HEAD.');
|
|
360
|
+
const head = (headRes.stdout || '').trim();
|
|
361
|
+
|
|
362
|
+
// Pass an argument array to avoid escaping ^{commit} in Windows shells.
|
|
363
|
+
const selRes = spawnSync('git', ['rev-parse', `${commitHash}^{commit}`], { cwd, encoding: 'utf-8' });
|
|
364
|
+
if (selRes.status !== 0) throw new Error('Commit to edit not found.');
|
|
365
|
+
const selected = (selRes.stdout || '').trim();
|
|
366
|
+
|
|
367
|
+
const isAncestor = spawnSync('git', ['merge-base', '--is-ancestor', selected, head], { cwd, stdio: 'ignore' });
|
|
368
|
+
if (isAncestor.status !== 0) throw new Error('This commit is not in the current branch history.');
|
|
369
|
+
|
|
370
|
+
// 3. List only commits from selected to HEAD to limit work to rewritten descendants.
|
|
371
|
+
let revRange = head;
|
|
372
|
+
const hasParent = spawnSync('git', ['rev-parse', '--verify', `${selected}^`], { cwd, stdio: 'ignore' });
|
|
373
|
+
if (hasParent.status === 0) {
|
|
374
|
+
revRange = `${selected}^..${head}`;
|
|
375
|
+
}
|
|
376
|
+
const listRes = spawnSync('git', ['rev-list', '--reverse', '--topo-order', revRange], { cwd, encoding: 'utf-8' });
|
|
377
|
+
if (listRes.status !== 0) throw new Error('Could not read commit history.');
|
|
378
|
+
const commits = (listRes.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
379
|
+
if (!commits.includes(selected)) throw new Error('This commit is not in the current branch history.');
|
|
380
|
+
|
|
381
|
+
const epoch = Math.floor(new Date(dateFormatted).getTime() / 1000);
|
|
382
|
+
if (!Number.isFinite(epoch)) throw new Error('Invalid commit timestamp.');
|
|
383
|
+
// Commit object dates use the format "<epoch> <tz>" without an @ character.
|
|
384
|
+
const gitDate = `${epoch} +0000`;
|
|
385
|
+
|
|
386
|
+
const remapped = new Map();
|
|
387
|
+
let changed = false;
|
|
388
|
+
|
|
389
|
+
for (const hash of commits) {
|
|
390
|
+
const catRes = spawnSync('git', ['cat-file', 'commit', hash], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
391
|
+
if (catRes.status !== 0) throw new Error(`Could not read commit ${hash.slice(0, 7)}.`);
|
|
392
|
+
const raw = catRes.stdout || '';
|
|
393
|
+
const splitAt = raw.indexOf('\n\n');
|
|
394
|
+
if (splitAt === -1) throw new Error(`Commit ${hash.slice(0, 7)} has an invalid format.`);
|
|
395
|
+
const headers = raw.slice(0, splitAt).split('\n');
|
|
396
|
+
const message = raw.slice(splitAt);
|
|
397
|
+
const parents = headers.filter((line) => line.startsWith('parent ')).map((line) => line.slice(7));
|
|
398
|
+
const mappedParents = parents.map((parent) => remapped.get(parent) || parent);
|
|
399
|
+
const parentChanged = mappedParents.some((parent, i) => parent !== parents[i]);
|
|
400
|
+
if (hash !== selected && !parentChanged) continue;
|
|
401
|
+
|
|
402
|
+
if (headers.some((line) => line === 'gpgsig' || line.startsWith('gpgsig '))) {
|
|
403
|
+
throw new Error('The history contains a signed commit; editing its date would invalidate the signature.');
|
|
404
|
+
}
|
|
405
|
+
let parentIndex = 0;
|
|
406
|
+
const newHeaders = headers.map((line) => {
|
|
407
|
+
if (line.startsWith('parent ')) return `parent ${mappedParents[parentIndex++]}`;
|
|
408
|
+
if (hash === selected && (line.startsWith('author ') || line.startsWith('committer '))) {
|
|
409
|
+
return line.replace(/\s-?\d+\s[+-]\d{4}$/, ` ${gitDate}`);
|
|
410
|
+
}
|
|
411
|
+
return line;
|
|
412
|
+
});
|
|
413
|
+
if (hash === selected && !newHeaders.some((line) => line.startsWith('author ') && line.endsWith(gitDate))) {
|
|
414
|
+
throw new Error('Could not parse commit metadata.');
|
|
415
|
+
}
|
|
416
|
+
const result = spawnSync('git', ['hash-object', '-t', 'commit', '-w', '--stdin'], {
|
|
417
|
+
cwd, input: `${newHeaders.join('\n')}${message}`, encoding: 'utf-8'
|
|
418
|
+
});
|
|
419
|
+
if (result.status !== 0) throw new Error((result.stderr || 'Could not create a new commit').trim());
|
|
420
|
+
remapped.set(hash, (result.stdout || '').trim());
|
|
421
|
+
changed = true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const newHead = remapped.get(head) || head;
|
|
425
|
+
if (!changed || newHead === head) throw new Error('Could not create the edited commit.');
|
|
426
|
+
|
|
427
|
+
// 4. Preserve the original history in a backup branch visible in `git branch`.
|
|
428
|
+
const backupBranch = `backup/before-date-edit-${Date.now()}-${head.slice(0, 7)}`;
|
|
429
|
+
const backupRef = `refs/heads/${backupBranch}`;
|
|
430
|
+
const bkRes = spawnSync('git', ['update-ref', backupRef, head], { cwd, encoding: 'utf-8' });
|
|
431
|
+
if (bkRes.status !== 0) throw new Error('Could not create a backup branch.');
|
|
432
|
+
const mvRes = spawnSync('git', ['update-ref', `refs/heads/${branch}`, newHead, head], { cwd, encoding: 'utf-8' });
|
|
433
|
+
if (mvRes.status !== 0) {
|
|
434
|
+
spawnSync('git', ['update-ref', '-d', backupRef], { cwd, stdio: 'ignore' });
|
|
435
|
+
throw new Error((mvRes.stderr || 'Could not update the branch after rewriting history.').trim());
|
|
436
|
+
}
|
|
437
|
+
return { oldHead: head, newHead, backupRef, backupBranch, rewrittenCount: remapped.size };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Classify `git push` failures so the CLI can show helpful guidance.
|
|
442
|
+
* Specifically handle GitHub Push Protection (GH013) secret blocks.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} rawOutput stderr/stdout from git push
|
|
445
|
+
* @returns {{ code: string, secretTypes: string[], files: string[], unblockUrl: string | null, resolveUrl: string | null }}
|
|
446
|
+
*/
|
|
447
|
+
export function parsePushError(rawOutput = '') {
|
|
448
|
+
const text = String(rawOutput || '');
|
|
449
|
+
const lower = text.toLowerCase();
|
|
450
|
+
|
|
451
|
+
const isSecretBlocked =
|
|
452
|
+
/gh0?13/.test(text) ||
|
|
453
|
+
lower.includes('push protection') ||
|
|
454
|
+
lower.includes('push cannot contain secrets') ||
|
|
455
|
+
lower.includes('secret scanning') ||
|
|
456
|
+
lower.includes('repository rule violations') ||
|
|
457
|
+
(lower.includes('blocked') && lower.includes('secret')) ||
|
|
458
|
+
(lower.includes('detected') && lower.includes('secret'));
|
|
459
|
+
|
|
460
|
+
if (isSecretBlocked) {
|
|
461
|
+
// Examples: "—— npm Access Token ——" or "— Generic API Key —".
|
|
462
|
+
const secretTypes = [];
|
|
463
|
+
const typeRegex = /[—\-]{2,}\s*([A-Za-z0-9 _.'()/+-]+?(?:token|key|secret|password|private key)[A-Za-z0-9 _.'()/+-]*?)\s*[—\-]{2,}/gi;
|
|
464
|
+
let m;
|
|
465
|
+
while ((m = typeRegex.exec(text)) !== null) {
|
|
466
|
+
const name = m[1].trim();
|
|
467
|
+
if (name && !secretTypes.includes(name)) secretTypes.push(name);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Examples: "- commit abc123: .npmrc:1" or "file: .env".
|
|
471
|
+
const files = [];
|
|
472
|
+
const fileRegex = /(?:commit\s+[0-9a-f]{4,40}\s*:\s*)([^\s:]+\.[A-Za-z0-9_.-]+)/gi;
|
|
473
|
+
while ((m = fileRegex.exec(text)) !== null) {
|
|
474
|
+
if (!files.includes(m[1])) files.push(m[1]);
|
|
475
|
+
}
|
|
476
|
+
// Fallback: find suspicious filenames in the output without a greedy .* match.
|
|
477
|
+
const fallbackRegex = /(^|\s)(`?\.?(env|npmrc|id_rsa|id_ed25519|[^\s`'"]*\.pem|[^\s`'"]*\.key))\b/gi;
|
|
478
|
+
while ((m = fallbackRegex.exec(text)) !== null) {
|
|
479
|
+
const f = m[2].replace(/`/g, '');
|
|
480
|
+
if (!files.includes(f)) files.push(f);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const unblockMatch = text.match(/https:\/\/github\.com\/[^\s) '"]*unblock-secret[^\s) '"]*/i);
|
|
484
|
+
const resolveMatch = text.match(/https:\/\/docs\.github\.com\/[^\s) '"]*push-protection[^\s) '"]*/i);
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
code: 'SECRET_BLOCKED',
|
|
488
|
+
secretTypes,
|
|
489
|
+
files,
|
|
490
|
+
unblockUrl: unblockMatch ? unblockMatch[0] : null,
|
|
491
|
+
resolveUrl: resolveMatch ? resolveMatch[0] : 'https://docs.github.com/code-security/secret-scanning/pushing-a-branch-blocked-by-push-protection'
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (lower.includes('authentication failed') || lower.includes('could not read username') || lower.includes('invalid username or password') || lower.includes('permission denied')) {
|
|
496
|
+
return { code: 'AUTH_FAILED', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
497
|
+
}
|
|
498
|
+
if (lower.includes('non-fast-forward') || lower.includes('failed to push some refs') || lower.includes('fetch first') || lower.includes('rejected')) {
|
|
499
|
+
return { code: 'NON_FAST_FORWARD', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
500
|
+
}
|
|
501
|
+
if (lower.includes('repository not found') || lower.includes('could not resolve host') || lower.includes('network') || lower.includes('unable to access')) {
|
|
502
|
+
return { code: 'REMOTE_OR_NETWORK', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
503
|
+
}
|
|
504
|
+
return { code: 'UNKNOWN', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Quickly scan staged files before commit/push.
|
|
509
|
+
* Warn without blocking to avoid false positives.
|
|
510
|
+
* @param {string} cwd
|
|
511
|
+
* @returns {string[]} List of suspicious files
|
|
512
|
+
*/
|
|
513
|
+
export function findRiskyStagedFiles(cwd = process.cwd()) {
|
|
514
|
+
const denyList = [/^\.env(\..+)?$/i, /^\.npmrc$/i, /_rsa$/, /_ed25519$/, /\.pem$/i, /\.key$/i, /secrets?\.json$/i, /credentials?\.json$/i, /\.p12$/i, /\.pfx$/i];
|
|
515
|
+
try {
|
|
516
|
+
const r = spawnSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
517
|
+
if (r.status !== 0) return [];
|
|
518
|
+
return (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean).filter((f) => {
|
|
519
|
+
const base = path.basename(f);
|
|
520
|
+
return denyList.some((re) => re.test(base) || re.test(f));
|
|
521
|
+
});
|
|
522
|
+
} catch {
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
}
|