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/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
- * Kiểm tra xem máy tính đã cài đặt Git hay chưa
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
- * Kiểm tra thư mục hiện tại đã là kho chứa Git (Git repo) chưa
20
- * @param {string} cwd
21
- * @returns {boolean}
22
- */
23
- export function isGitRepo(cwd = process.cwd()) {
24
- // Dùng spawnSync dạng mảng args (không qua shell) để tránh lỗi trên Windows
25
- // khi folder chứa file tên git.* (vd: git.js) — cmd.exe sẽ nhầm `git` thành file đó do 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
- * Khởi tạo Git repo trong thư mục hiện tại
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 thất bại').trim());
38
- }
39
- }
40
-
41
- /**
42
- * Lấy top-level của Git repo chứa `cwd` (đi ngược lên cha).
43
- * Trả về null nếu không nằm trong repo nào.
44
- * @param {string} cwd
45
- * @returns {string|null}
46
- */
47
- export function getGitTopLevel(cwd = process.cwd()) {
48
- // Dùng spawnSync (không qua shell) — xem ghi chú ở 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
- * Liệt kê các thư mục con trực tiếp của `cwd` để user chọn folder nguồn push.
57
- * Bỏ qua .git để tránh nhầm. Sắp xếp: thư mục thường trước, ẩn sau.
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
- // Bỏ qua symlink gãy
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
- * Chuẩn hoá đường dẫn folder nguồn do user nhập/truyền vào.
90
- * @param {string} inputPath đường dẫn tương đối hoặc tuyệt đối
91
- * @param {string} base thư mục gốc để resolve đường dẫn tương đối
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: 'Đường dẫn trống.' };
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}' không phải là thư mục.` };
102
- } catch {
103
- return { ok: false, error: `Không tìm thấy thư mục '${inputPath}'.` };
104
- }
105
- return { ok: true, fullPath };
106
- }
107
-
108
- /**
109
- * Lấy tên branch hiện tại (hoặc mặc định là main cho hiển thị).
110
- * Lưu ý: trả về 'main' khi detached HEAD để tương thích UI cũ.
111
- * Dùng getCurrentBranchStrict() khi cần phát hiện detached chính xác.
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
- * Lấy tên branch hiện tại, trả về null khi detached HEAD hoặc lỗi.
145
- * @param {string} cwd
146
- * @returns {string|null}
147
- */
148
- export function getCurrentBranchStrict(cwd = process.cwd()) {
149
- // Dùng spawnSync (không qua shell) — xem ghi chú ở 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 tên branch để chống 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
- // Cấm ký tự điều khiển, space đầu/cuối, và các ký tự git cấm: ~ ^ : ? * [ \ ..
165
- // Cho phép chữ, số, / - _ . đơn giản nhất; từ chối còn lại để an toàn.
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
- * Thiết lập tên branch chính (ví dụ: 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(`Tên branch không hợp lệ: '${branchName}'`);
181
- }
182
- const r = spawnSync('git', ['branch', '-M', clean], { cwd, encoding: 'utf-8' });
183
- if (r.status !== 0) {
184
- // Nếu chưa có commit nào, command này có thể báo lỗi nhưng không sao
185
- const msg = String(r.stderr || r.stdout || '');
186
- if (!/no commits yet|unknown revision|ambiguous/i.test(msg)) {
187
- // Im lặng tương thích cũ; caller sẽ phát hiện ở bước push nếu thật sự lỗi
188
- }
189
- }
190
- }
191
-
192
- /**
193
- * Cấu hình remote `origin` cho Git repo
194
- * @param {string} repoUrl
195
- * @param {string} cwd
196
- */
197
- export function setGitRemote(repoUrl, cwd = process.cwd()) {
198
- if (!repoUrl || typeof repoUrl !== 'string' || !repoUrl.trim()) {
199
- throw new Error('URL repository trống.');
200
- }
201
- const url = repoUrl.trim();
202
- try {
203
- const rList = spawnSync('git', ['remote'], { cwd, encoding: 'utf-8' });
204
- const remotes = (rList.stdout || '').split(/\r?\n/).map((s) => s.trim());
205
- let r;
206
- if (remotes.includes('origin')) {
207
- r = spawnSync('git', ['remote', 'set-url', 'origin', url], { cwd, encoding: 'utf-8' });
208
- } else {
209
- r = spawnSync('git', ['remote', 'add', 'origin', url], { cwd, encoding: 'utf-8' });
210
- }
211
- if (r.status !== 0) {
212
- throw new Error(String(r.stderr || r.stdout || 'git remote thất bại').trim());
213
- }
214
- } catch (error) {
215
- throw new Error(`Không thể thiết lập Git Remote URL: ${error.message}`);
216
- }
217
- }
218
-
219
- /**
220
- * Stage toàn bộ file trong thư mục
221
- * @param {string} cwd
222
- */
223
- export function stageAllFiles(cwd = process.cwd()) {
224
- const r = spawnSync('git', ['add', '.'], { cwd, encoding: 'utf-8' });
225
- if (r.status !== 0) {
226
- throw new Error(String(r.stderr || r.stdout || 'git add thất bại').trim());
227
- }
228
- }
229
-
230
- /**
231
- * Kiểm tra xem có thay đổi (staged hoặc unstaged) chưa commit hay không
232
- * @param {string} cwd
233
- * @returns {boolean}
234
- */
235
- export function hasChangesToCommit(cwd = process.cwd()) {
236
- try {
237
- const r = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
238
- if (r.status !== 0) return true;
239
- return (r.stdout || '').trim().length > 0;
240
- } catch {
241
- return true;
242
- }
243
- }
244
-
245
- /**
246
- * Thực hiện Git commit với thời gian tùy chỉnh (sử dụng GIT_AUTHOR_DATE & GIT_COMMITTER_DATE)
247
- * @param {string} message
248
- * @param {string} dateFormatted ISO hoặc Git date format
249
- * @param {string} cwd
250
- */
251
- export function commitWithCustomDate(message, dateFormatted, cwd = process.cwd()) {
252
- const env = {
253
- ...process.env,
254
- GIT_AUTHOR_DATE: dateFormatted,
255
- GIT_COMMITTER_DATE: dateFormatted
256
- };
257
-
258
- const result = spawnSync('git', ['commit', '-m', message], {
259
- cwd,
260
- env,
261
- encoding: 'utf-8'
262
- });
263
-
264
- if (result.status !== 0) {
265
- const stderr = result.stderr || result.stdout || 'Lỗi không xác định khi commit';
266
- throw new Error(`Git commit thất bại: ${stderr.trim()}`);
267
- }
268
- }
269
-
270
- /**
271
- * Đẩy code lên Git remote repo
272
- * @param {string} branch Tên branch (VD: main)
273
- * @param {boolean} force Có sử dụng --force hay không
274
- * @param {string} cwd
275
- */
276
- export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()) {
277
- const args = ['push', '-u', 'origin', branch];
278
- if (force) {
279
- args.push('--force');
280
- }
281
-
282
- const result = spawnSync('git', args, {
283
- cwd,
284
- encoding: 'utf-8',
285
- stdio: 'pipe'
286
- });
287
-
288
- if (result.status !== 0) {
289
- const raw = (result.stderr || result.stdout || 'Lỗi không xác định khi git push').trim();
290
- const parsed = parsePushError(raw);
291
- const err = new Error(raw);
292
- err.code = parsed.code;
293
- err.details = parsed;
294
- throw err;
295
- }
296
- }
297
-
298
- export function pullFromRemote(remote = 'origin', branch = null, cwd = process.cwd(), { rebase = false } = {}) {
299
- const targetBranch = branch || getCurrentBranchStrict(cwd);
300
- if (!targetBranch) {
301
- throw new Error('Đang ở detached HEAD nên không xác định được branch để pull.');
302
- }
303
- const strategy = rebase ? '--rebase' : '--ff-only';
304
- const result = spawnSync('git', ['pull', strategy, remote, targetBranch], { cwd, encoding: 'utf-8', stdio: 'pipe' });
305
- if (result.status !== 0) {
306
- throw new Error((result.stderr || result.stdout || `Git pull ${strategy} thất bại`).trim());
307
- }
308
- return (result.stdout || '').trim();
309
- }
310
-
311
- export function getRecentCommits(limit = 30, cwd = process.cwd()) {
312
- const safeLimit = Math.max(1, Math.min(100, Number(limit) || 30));
313
- // Dùng mảng args (không qua shell) để an toàn trên Windows (tránh lỗi với %, ^, {}).
314
- // --date=iso để hiển thị cả giờ phút khi chọn commit.
315
- const result = spawnSync('git', ['log', `--max-count=${safeLimit}`, '--format=%H%x09%h%x09%ad%x09%s', '--date=iso'], {
316
- cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe']
317
- });
318
- if (result.status !== 0) {
319
- // Repo chưa có commit nào -> trả về rỗng để caller báo lỗi thân thiện.
320
- return [];
321
- }
322
- const output = (result.stdout || '').trim();
323
- if (!output) return [];
324
- return output.split(/\r?\n/).filter(Boolean).map((line) => {
325
- const [hash, shortHash, date, ...subject] = line.split('\t');
326
- return { hash, shortHash, date, subject: subject.join('\t') };
327
- });
328
- }
329
-
330
- /**
331
- * Sửa Author Date + Committer Date của 1 commit.
332
- * - Yêu cầu working tree sạch.
333
- * - Chỉ cho chạy trên branch thường (không detached HEAD).
334
- * - Commit phải nằm trên lịch sử branch hiện tại.
335
- * - Viết lại hash của commit đó và mọi commit phía sau (descendants).
336
- * - Giữ bản gốc bằng một nhánh backup tự động: backup/before-date-edit-*
337
- */
338
- export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()) {
339
- // 1. Working tree phải sạch.
340
- const statusRes = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
341
- if (statusRes.status !== 0) throw new Error('Không đọc được trạng thái working tree.');
342
- if ((statusRes.stdout || '').trim()) throw new Error('Hãy commit hoặc stash thay đổi trước khi sửa thời gian commit.');
343
-
344
- // 2. Phải ở trên branch thường.
345
- const branchRes = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
346
- const branch = (branchRes.stdout || '').trim();
347
- if (!branch) throw new Error('Không hỗ trợ sửa commit khi đang ở detached HEAD.');
348
-
349
- const headRes = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' });
350
- if (headRes.status !== 0) throw new Error('Không xác định được HEAD của branch hiện tại.');
351
- const head = (headRes.stdout || '').trim();
352
-
353
- // Dùng mảng args để tránh shell escape ^{commit} trên Windows (cmd/PowerShell).
354
- const selRes = spawnSync('git', ['rev-parse', `${commitHash}^{commit}`], { cwd, encoding: 'utf-8' });
355
- if (selRes.status !== 0) throw new Error('Không tìm thấy commit cần sửa.');
356
- const selected = (selRes.stdout || '').trim();
357
-
358
- const isAncestor = spawnSync('git', ['merge-base', '--is-ancestor', selected, head], { cwd, stdio: 'ignore' });
359
- if (isAncestor.status !== 0) throw new Error('Commit này không nằm trên lịch sử của branch hiện tại.');
360
-
361
- // 3. Chỉ liệt kê commit từ selected tới HEAD (đỡ nặng, đúng semantics viết lại descendants).
362
- let revRange = head;
363
- const hasParent = spawnSync('git', ['rev-parse', '--verify', `${selected}^`], { cwd, stdio: 'ignore' });
364
- if (hasParent.status === 0) {
365
- revRange = `${selected}^..${head}`;
366
- }
367
- const listRes = spawnSync('git', ['rev-list', '--reverse', '--topo-order', revRange], { cwd, encoding: 'utf-8' });
368
- if (listRes.status !== 0) throw new Error('Không đọc được lịch sử commit.');
369
- const commits = (listRes.stdout || '').trim().split(/\r?\n/).filter(Boolean);
370
- if (!commits.includes(selected)) throw new Error('Commit này không nằm trên lịch sử của branch hiện tại.');
371
-
372
- const epoch = Math.floor(new Date(dateFormatted).getTime() / 1000);
373
- if (!Number.isFinite(epoch)) throw new Error('Thời gian commit không hợp lệ.');
374
- // Format trong object commit là "<epoch> <tz>", KHÔNG có ký tự @.
375
- const gitDate = `${epoch} +0000`;
376
-
377
- const remapped = new Map();
378
- let changed = false;
379
-
380
- for (const hash of commits) {
381
- const catRes = spawnSync('git', ['cat-file', 'commit', hash], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
382
- if (catRes.status !== 0) throw new Error(`Không đọc được commit ${hash.slice(0, 7)}.`);
383
- const raw = catRes.stdout || '';
384
- const splitAt = raw.indexOf('\n\n');
385
- if (splitAt === -1) throw new Error(`Commit ${hash.slice(0, 7)} có định dạng không hợp lệ.`);
386
- const headers = raw.slice(0, splitAt).split('\n');
387
- const message = raw.slice(splitAt);
388
- const parents = headers.filter((line) => line.startsWith('parent ')).map((line) => line.slice(7));
389
- const mappedParents = parents.map((parent) => remapped.get(parent) || parent);
390
- const parentChanged = mappedParents.some((parent, i) => parent !== parents[i]);
391
- if (hash !== selected && !parentChanged) continue;
392
-
393
- if (headers.some((line) => line === 'gpgsig' || line.startsWith('gpgsig '))) {
394
- throw new Error('Lịch sử có commit đã ký số; sửa ngày sẽ làm chữ ký mất hiệu lực.');
395
- }
396
- let parentIndex = 0;
397
- const newHeaders = headers.map((line) => {
398
- if (line.startsWith('parent ')) return `parent ${mappedParents[parentIndex++]}`;
399
- if (hash === selected && (line.startsWith('author ') || line.startsWith('committer '))) {
400
- return line.replace(/\s-?\d+\s[+-]\d{4}$/, ` ${gitDate}`);
401
- }
402
- return line;
403
- });
404
- if (hash === selected && !newHeaders.some((line) => line.startsWith('author ') && line.endsWith(gitDate))) {
405
- throw new Error('Không thể phân tích metadata của commit.');
406
- }
407
- const result = spawnSync('git', ['hash-object', '-t', 'commit', '-w', '--stdin'], {
408
- cwd, input: `${newHeaders.join('\n')}${message}`, encoding: 'utf-8'
409
- });
410
- if (result.status !== 0) throw new Error((result.stderr || 'Không thể tạo commit mới').trim());
411
- remapped.set(hash, (result.stdout || '').trim());
412
- changed = true;
413
- }
414
-
415
- const newHead = remapped.get(head) || head;
416
- if (!changed || newHead === head) throw new Error('Không tạo được commit đã sửa.');
417
-
418
- // 4. Giữ bản gốc bằng nhánh backup thật để user thấy trong `git branch`.
419
- const backupBranch = `backup/before-date-edit-${Date.now()}-${head.slice(0, 7)}`;
420
- const backupRef = `refs/heads/${backupBranch}`;
421
- const bkRes = spawnSync('git', ['update-ref', backupRef, head], { cwd, encoding: 'utf-8' });
422
- if (bkRes.status !== 0) throw new Error('Không tạo được nhánh backup.');
423
- const mvRes = spawnSync('git', ['update-ref', `refs/heads/${branch}`, newHead, head], { cwd, encoding: 'utf-8' });
424
- if (mvRes.status !== 0) {
425
- spawnSync('git', ['update-ref', '-d', backupRef], { cwd, stdio: 'ignore' });
426
- throw new Error((mvRes.stderr || 'Không thể cập nhật branch sau khi viết lại.').trim());
427
- }
428
- return { oldHead: head, newHead, backupRef, backupBranch, rewrittenCount: remapped.size };
429
- }
430
-
431
- /**
432
- * Phân loại lỗi khi `git push` thất bại để CLI hiển thị hướng dẫn thân thiện.
433
- * Đặc biệt xử lý GitHub Push Protection (GH013) khi push dính secret.
434
- *
435
- * @param {string} rawOutput stderr/stdout từ git push
436
- * @returns {{ code: string, secretTypes: string[], files: string[], unblockUrl: string | null, resolveUrl: string | null }}
437
- */
438
- export function parsePushError(rawOutput = '') {
439
- const text = String(rawOutput || '');
440
- const lower = text.toLowerCase();
441
-
442
- const isSecretBlocked =
443
- /gh0?13/.test(text) ||
444
- lower.includes('push protection') ||
445
- lower.includes('push cannot contain secrets') ||
446
- lower.includes('secret scanning') ||
447
- lower.includes('repository rule violations') ||
448
- (lower.includes('blocked') && lower.includes('secret')) ||
449
- (lower.includes('detected') && lower.includes('secret'));
450
-
451
- if (isSecretBlocked) {
452
- // VD: "—— npm Access Token ——" hoặc "— Generic API Key —"
453
- const secretTypes = [];
454
- const typeRegex = /[—\-]{2,}\s*([A-Za-z0-9 _.'()/+-]+?(?:token|key|secret|password|private key)[A-Za-z0-9 _.'()/+-]*?)\s*[—\-]{2,}/gi;
455
- let m;
456
- while ((m = typeRegex.exec(text)) !== null) {
457
- const name = m[1].trim();
458
- if (name && !secretTypes.includes(name)) secretTypes.push(name);
459
- }
460
-
461
- // VD: "- commit abc123: .npmrc:1" hoặc "file: .env"
462
- const files = [];
463
- const fileRegex = /(?:commit\s+[0-9a-f]{4,40}\s*:\s*)([^\s:]+\.[A-Za-z0-9_.-]+)/gi;
464
- while ((m = fileRegex.exec(text)) !== null) {
465
- if (!files.includes(m[1])) files.push(m[1]);
466
- }
467
- // fallback: tìm tên file khả nghi trong output (tránh greedy .* nuốt spaces)
468
- const fallbackRegex = /(^|\s)(`?\.?(env|npmrc|id_rsa|id_ed25519|[^\s`'"]*\.pem|[^\s`'"]*\.key))\b/gi;
469
- while ((m = fallbackRegex.exec(text)) !== null) {
470
- const f = m[2].replace(/`/g, '');
471
- if (!files.includes(f)) files.push(f);
472
- }
473
-
474
- const unblockMatch = text.match(/https:\/\/github\.com\/[^\s) '"]*unblock-secret[^\s) '"]*/i);
475
- const resolveMatch = text.match(/https:\/\/docs\.github\.com\/[^\s) '"]*push-protection[^\s) '"]*/i);
476
-
477
- return {
478
- code: 'SECRET_BLOCKED',
479
- secretTypes,
480
- files,
481
- unblockUrl: unblockMatch ? unblockMatch[0] : null,
482
- resolveUrl: resolveMatch ? resolveMatch[0] : 'https://docs.github.com/code-security/secret-scanning/pushing-a-branch-blocked-by-push-protection'
483
- };
484
- }
485
-
486
- if (lower.includes('authentication failed') || lower.includes('could not read username') || lower.includes('invalid username or password') || lower.includes('permission denied')) {
487
- return { code: 'AUTH_FAILED', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
488
- }
489
- if (lower.includes('non-fast-forward') || lower.includes('failed to push some refs') || lower.includes('fetch first') || lower.includes('rejected')) {
490
- return { code: 'NON_FAST_FORWARD', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
491
- }
492
- if (lower.includes('repository not found') || lower.includes('could not resolve host') || lower.includes('network') || lower.includes('unable to access')) {
493
- return { code: 'REMOTE_OR_NETWORK', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
494
- }
495
- return { code: 'UNKNOWN', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
496
- }
497
-
498
- /**
499
- * Quét nhanh staged files để cảnh báo TRƯỚC khi commit/push.
500
- * Chỉ cảnh báo, không chặn — tránh false-positive.
501
- * @param {string} cwd
502
- * @returns {string[]} danh sách file khả nghi
503
- */
504
- export function findRiskyStagedFiles(cwd = process.cwd()) {
505
- const denyList = [/^\.env(\..+)?$/i, /^\.npmrc$/i, /_rsa$/, /_ed25519$/, /\.pem$/i, /\.key$/i, /secrets?\.json$/i, /credentials?\.json$/i, /\.p12$/i, /\.pfx$/i];
506
- try {
507
- const r = spawnSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
508
- if (r.status !== 0) return [];
509
- return (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean).filter((f) => {
510
- const base = path.basename(f);
511
- return denyList.some((re) => re.test(base) || re.test(f));
512
- });
513
- } catch {
514
- return [];
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
+ }