git-push-time 1.0.3 → 1.1.1

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