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/fix.js CHANGED
@@ -1,260 +1,286 @@
1
- import { spawnSync, execSync } from 'child_process';
2
- import fs from 'fs';
3
- import path from 'path';
4
- import { findRiskyStagedFiles } from './git.js';
5
- import { scanStagedContentForSecrets } from './secrets.js';
6
-
7
- /**
8
- * Danh sách phần mở rộng của file mã nguồn / tài liệu.
9
- * KHÔNG BAO GIỜ tự động thêm các file này vào .gitignore vì sẽ làm mất code dự án!
10
- */
11
- const CODE_EXTENSIONS = new Set([
12
- '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
13
- '.py', '.java', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
14
- '.cs', '.php', '.rb', '.html', '.htm', '.css', '.scss', '.sass',
15
- '.less', '.vue', '.svelte', '.md', '.markdown', '.txt', '.sh',
16
- '.bash', '.zsh', '.bat', '.cmd', '.ps1', '.sql'
17
- ]);
18
-
19
- function isCodeOrDocFile(filePath) {
20
- const base = path.basename(filePath).toLowerCase();
21
- const ext = path.extname(filePath).toLowerCase();
22
- if (base === 'package.json' || base === 'package-lock.json' || base === 'tsconfig.json') return true;
23
- return CODE_EXTENSIONS.has(ext);
24
- }
25
-
26
- /**
27
- * Tự fix an toàn khi phát hiện secret:
28
- * 1. git rm --cached -r -f <file> (gỡ khỏi commit, giữ nguyên file trên máy)
29
- * 2. Thêm rule tương ứng vào .gitignore (chỉ áp dụng cho file cấu hình secret, KHÔNG ignore file code)
30
- * 3. Tự động stage lại .gitignore để sẵn sàng commit
31
- *
32
- * @param {string[]} files danh sách file cần gỡ (vd ['.env', '.npmrc'])
33
- * @param {string} cwd
34
- * @returns {{ fixed: string[], alreadyClean: string[], failed: {file:string, reason:string}[], gitignoreAdded: string[], codeFilesWithSecrets: string[] }}
35
- */
36
- export function autoFixStagedSecrets(files = [], cwd = process.cwd()) {
37
- const unique = [...new Set((files || []).map((f) => String(f).trim()).filter(Boolean))];
38
- const result = {
39
- fixed: [],
40
- alreadyClean: [],
41
- failed: [],
42
- gitignoreAdded: [],
43
- codeFilesWithSecrets: []
44
- };
45
- if (unique.length === 0) return result;
46
-
47
- for (const file of unique) {
48
- const normalized = file.replace(/\\/g, '/');
49
- let ok = false;
50
- let errReason = '';
51
-
52
- // 1. Thử gỡ khỏi stage bằng git rm --cached -r -f
53
- const r = spawnSync('git', ['rm', '--cached', '-r', '-f', '--', normalized], { cwd, encoding: 'utf-8' });
54
- if (r.status === 0) {
55
- ok = true;
56
- } else {
57
- const msg = String(r.stderr || r.stdout || '');
58
- // Thử fallback bằng git restore --staged nếu git rm báo pathspec/not matched
59
- const rRestore = spawnSync('git', ['restore', '--staged', '--', normalized], { cwd, encoding: 'utf-8' });
60
- if (rRestore.status === 0) {
61
- ok = true;
62
- } else if (/did not match|pathspec/i.test(msg)) {
63
- result.alreadyClean.push(file);
64
- continue;
65
- } else {
66
- errReason = msg.trim().split('\n')[0] || 'unknown error';
67
- }
68
- }
69
-
70
- if (ok) {
71
- result.fixed.push(file);
72
- } else if (errReason) {
73
- result.failed.push({ file, reason: errReason });
74
- }
75
- }
76
-
77
- // 2. Phân loại và tạo rule .gitignore cho các file config secret (bỏ qua file code)
78
- const rules = [];
79
- for (const f of unique) {
80
- const normalized = f.replace(/\\/g, '/');
81
- const base = path.basename(normalized);
82
-
83
- if (isCodeOrDocFile(normalized)) {
84
- // File mã nguồn có dính secret: gỡ khỏi stage nhưng không cho vào .gitignore
85
- result.codeFilesWithSecrets.push(f);
86
- continue;
87
- }
88
-
89
- if (/^\.env(\..+)?$/i.test(base)) {
90
- rules.push('.env*');
91
- } else if (base.toLowerCase() === '.npmrc') {
92
- rules.push('.npmrc');
93
- } else if (/\.pem$|\.key$/i.test(base)) {
94
- rules.push('*.pem\n*.key');
95
- } else if (/id_rsa|id_ed25519|id_ecdsa|id_dsa/i.test(base)) {
96
- rules.push('id_rsa*\nid_ed25519*\nid_ecdsa*');
97
- } else if (/secrets?\.json$|credentials?\.json$|service-account.*\.json$/i.test(base)) {
98
- rules.push('*secret*.json\n*credential*.json');
99
- } else if (/\.pfx$|\.p12$/i.test(base)) {
100
- rules.push('*.pfx\n*.p12');
101
- } else {
102
- rules.push(normalized);
103
- }
104
- }
105
-
106
- const gitignorePath = path.join(cwd, '.gitignore');
107
- let existing = '';
108
- try {
109
- if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf-8');
110
- } catch { existing = ''; }
111
-
112
- const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
113
- const toAdd = [];
114
- for (const chunk of rules) {
115
- for (const line of String(chunk).split(/\r?\n/)) {
116
- const t = line.trim();
117
- if (t && !existingLines.has(t)) {
118
- toAdd.push(t);
119
- existingLines.add(t);
120
- }
121
- }
122
- }
123
-
124
- if (toAdd.length > 0) {
125
- try {
126
- const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
127
- fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf-8');
128
- result.gitignoreAdded = toAdd;
129
-
130
- // Tự động stage ngay .gitignore để không bị sót khi commit
131
- spawnSync('git', ['add', '.gitignore'], { cwd });
132
- } catch {
133
- // Bỏ qua lỗi ghi file
134
- }
135
- }
136
-
137
- return result;
138
- }
139
-
140
- /** Kiểm tra còn gì để commit sau khi auto-fix không */
141
- export function hasStagedChanges(cwd = process.cwd()) {
142
- try {
143
- const out = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
144
- return out.trim().length > 0;
145
- } catch {
146
- return false;
147
- }
148
- }
149
-
150
- /**
151
- * Quét các file secret phổ biến trong thư mục làm việc nhưng chưa được ignore trong .gitignore
152
- */
153
- export function findRiskyWorkingTreeFiles(cwd = process.cwd()) {
154
- const denyNames = [
155
- '.env', '.env.local', '.env.development', '.env.production',
156
- '.npmrc', 'secrets.json', 'secret.json', 'credentials.json',
157
- 'id_rsa', 'id_ed25519'
158
- ];
159
- const found = [];
160
- for (const name of denyNames) {
161
- const fullPath = path.join(cwd, name);
162
- if (fs.existsSync(fullPath)) {
163
- const r = spawnSync('git', ['check-ignore', '-q', name], { cwd });
164
- if (r.status !== 0) {
165
- found.push(name);
166
- }
167
- }
168
- }
169
- return found;
170
- }
171
-
172
- /**
173
- * Tìm upstream ref của branch hiện tại (VD: origin/main hoặc origin/master)
174
- */
175
- export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
176
- try {
177
- const upstream = execSync(`git rev-parse --abbrev-ref ${branch}@{u}`, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
178
- if (upstream) return upstream;
179
- } catch {}
180
-
181
- const candidates = [`origin/${branch}`, 'origin/main', 'origin/master'];
182
- for (const ref of candidates) {
183
- try {
184
- execSync(`git rev-parse --verify ${ref}`, { cwd, stdio: 'ignore' });
185
- return ref;
186
- } catch {}
187
- }
188
- return null;
189
- }
190
-
191
- /**
192
- * Lấy danh sách các commit unpushed (chưa đẩy lên remote)
193
- */
194
- export function getUnpushedCommits(branch = 'main', cwd = process.cwd()) {
195
- const upstream = getUpstreamRef(branch, cwd);
196
- if (!upstream) {
197
- try {
198
- const log = execSync('git log --oneline', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
199
- return log.trim().split(/\r?\n/).filter(Boolean);
200
- } catch {
201
- return [];
202
- }
203
- }
204
- try {
205
- const log = execSync(`git log ${upstream}..HEAD --oneline`, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
206
- return log.trim().split(/\r?\n/).filter(Boolean);
207
- } catch {
208
- return [];
209
- }
210
- }
211
-
212
- /**
213
- * Squash các commit chưa push thành 1 commit duy nhất sạch sẽ,
214
- * loại bỏ hoàn toàn dấu vết secret ở các commit trung gian (ví dụ commit cũ chứa .npmrc hay .env).
215
- */
216
- export function squashUnpushedCommits(branch = 'main', commitMsg = 'Clean commit without secrets', dateFormatted = null, cwd = process.cwd()) {
217
- const upstream = getUpstreamRef(branch, cwd);
218
- try {
219
- if (upstream) {
220
- const r1 = spawnSync('git', ['reset', '--soft', upstream], { cwd, encoding: 'utf-8' });
221
- if (r1.status !== 0) {
222
- throw new Error(r1.stderr || 'Không thể reset về upstream');
223
- }
224
- } else {
225
- // Chưa có upstream (repo mới hoàn toàn)
226
- const rootCommit = execSync('git rev-list --max-parents=0 HEAD', { cwd, encoding: 'utf-8' }).trim().split(/\s+/)[0];
227
- if (rootCommit) {
228
- spawnSync('git', ['reset', '--soft', rootCommit], { cwd });
229
- }
230
- }
231
-
232
- // Quét lại và loại bỏ bất kỳ secret file nào còn sót trong staged
233
- const risky = findRiskyStagedFiles(cwd);
234
- const findings = scanStagedContentForSecrets(cwd);
235
- const allSecretFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
236
- if (allSecretFiles.length > 0) {
237
- autoFixStagedSecrets(allSecretFiles, cwd);
238
- }
239
-
240
- // Đảm bảo .gitignore được stage
241
- spawnSync('git', ['add', '.gitignore'], { cwd });
242
-
243
- // Tạo commit mới sạch
244
- const env = { ...process.env };
245
- if (dateFormatted) {
246
- env.GIT_AUTHOR_DATE = dateFormatted;
247
- env.GIT_COMMITTER_DATE = dateFormatted;
248
- }
249
- const rCommit = spawnSync('git', ['commit', '-m', commitMsg], { cwd, env, encoding: 'utf-8' });
250
- if (rCommit.status !== 0) {
251
- const msg = rCommit.stderr || rCommit.stdout || '';
252
- if (!msg.includes('nothing to commit')) {
253
- throw new Error(msg || 'Lỗi khi tạo commit squash');
254
- }
255
- }
256
- return { ok: true };
257
- } catch (err) {
258
- return { ok: false, error: err.message };
259
- }
260
- }
1
+ import { spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { findRiskyStagedFiles } from './git.js';
5
+ import { scanStagedContentForSecrets } from './secrets.js';
6
+
7
+ /**
8
+ * Danh sách phần mở rộng của file mã nguồn / tài liệu.
9
+ * KHÔNG BAO GIỜ tự động thêm các file này vào .gitignore vì sẽ làm mất code dự án!
10
+ */
11
+ const CODE_EXTENSIONS = new Set([
12
+ '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
13
+ '.py', '.java', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
14
+ '.cs', '.php', '.rb', '.html', '.htm', '.css', '.scss', '.sass',
15
+ '.less', '.vue', '.svelte', '.md', '.markdown', '.txt', '.sh',
16
+ '.bash', '.zsh', '.bat', '.cmd', '.ps1', '.sql'
17
+ ]);
18
+
19
+ function isCodeOrDocFile(filePath) {
20
+ const base = path.basename(filePath).toLowerCase();
21
+ const ext = path.extname(filePath).toLowerCase();
22
+ if (base === 'package.json' || base === 'package-lock.json' || base === 'tsconfig.json') return true;
23
+ return CODE_EXTENSIONS.has(ext);
24
+ }
25
+
26
+ /**
27
+ * Tự fix an toàn khi phát hiện secret:
28
+ * 1. git rm --cached -r -f <file> (gỡ khỏi commit, giữ nguyên file trên máy)
29
+ * 2. Thêm rule tương ứng vào .gitignore (chỉ áp dụng cho file cấu hình secret, KHÔNG ignore file code)
30
+ * 3. Tự động stage lại .gitignore để sẵn sàng commit
31
+ *
32
+ * @param {string[]} files danh sách file cần gỡ (vd ['.env', '.npmrc'])
33
+ * @param {string} cwd
34
+ * @returns {{ fixed: string[], alreadyClean: string[], failed: {file:string, reason:string}[], gitignoreAdded: string[], codeFilesWithSecrets: string[] }}
35
+ */
36
+ export function autoFixStagedSecrets(files = [], cwd = process.cwd()) {
37
+ const unique = [...new Set((files || []).map((f) => String(f).trim()).filter(Boolean))];
38
+ const result = {
39
+ fixed: [],
40
+ alreadyClean: [],
41
+ failed: [],
42
+ gitignoreAdded: [],
43
+ codeFilesWithSecrets: []
44
+ };
45
+ if (unique.length === 0) return result;
46
+
47
+ for (const file of unique) {
48
+ const normalized = file.replace(/\\/g, '/');
49
+ let ok = false;
50
+ let errReason = '';
51
+
52
+ // 1. Thử gỡ khỏi stage bằng git rm --cached -r -f
53
+ const r = spawnSync('git', ['rm', '--cached', '-r', '-f', '--', normalized], { cwd, encoding: 'utf-8' });
54
+ if (r.status === 0) {
55
+ ok = true;
56
+ } else {
57
+ const msg = String(r.stderr || r.stdout || '');
58
+ // Thử fallback bằng git restore --staged nếu git rm báo pathspec/not matched
59
+ const rRestore = spawnSync('git', ['restore', '--staged', '--', normalized], { cwd, encoding: 'utf-8' });
60
+ if (rRestore.status === 0) {
61
+ ok = true;
62
+ } else if (/did not match|pathspec/i.test(msg)) {
63
+ result.alreadyClean.push(file);
64
+ continue;
65
+ } else {
66
+ errReason = msg.trim().split('\n')[0] || 'unknown error';
67
+ }
68
+ }
69
+
70
+ if (ok) {
71
+ result.fixed.push(file);
72
+ } else {
73
+ result.failed.push({ file, reason: errReason || 'không gỡ được khỏi stage' });
74
+ }
75
+ }
76
+
77
+ // 2. Phân loại và tạo rule .gitignore cho các file config secret (bỏ qua file code)
78
+ const rules = [];
79
+ for (const f of unique) {
80
+ const normalized = f.replace(/\\/g, '/');
81
+ const base = path.basename(normalized);
82
+
83
+ if (isCodeOrDocFile(normalized)) {
84
+ // File mã nguồn có dính secret: gỡ khỏi stage nhưng không cho vào .gitignore
85
+ result.codeFilesWithSecrets.push(f);
86
+ continue;
87
+ }
88
+
89
+ if (/^\.env(\..+)?$/i.test(base)) {
90
+ rules.push('.env*');
91
+ } else if (base.toLowerCase() === '.npmrc') {
92
+ rules.push('.npmrc');
93
+ } else if (/\.pem$|\.key$/i.test(base)) {
94
+ rules.push('*.pem\n*.key');
95
+ } else if (/id_rsa|id_ed25519|id_ecdsa|id_dsa/i.test(base)) {
96
+ rules.push('id_rsa*\nid_ed25519*\nid_ecdsa*');
97
+ } else if (/secrets?\.json$|credentials?\.json$|service-account.*\.json$/i.test(base)) {
98
+ rules.push('*secret*.json\n*credential*.json');
99
+ } else if (/\.pfx$|\.p12$/i.test(base)) {
100
+ rules.push('*.pfx\n*.p12');
101
+ } else {
102
+ rules.push(normalized);
103
+ }
104
+ }
105
+
106
+ const gitignorePath = path.join(cwd, '.gitignore');
107
+ let existing = '';
108
+ try {
109
+ if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf-8');
110
+ } catch { existing = ''; }
111
+
112
+ const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
113
+ const toAdd = [];
114
+ for (const chunk of rules) {
115
+ for (const line of String(chunk).split(/\r?\n/)) {
116
+ const t = line.trim();
117
+ if (t && !existingLines.has(t)) {
118
+ toAdd.push(t);
119
+ existingLines.add(t);
120
+ }
121
+ }
122
+ }
123
+
124
+ if (toAdd.length > 0) {
125
+ try {
126
+ const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
127
+ fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf-8');
128
+ result.gitignoreAdded = toAdd;
129
+
130
+ // Tự động stage ngay .gitignore để không bị sót khi commit
131
+ spawnSync('git', ['add', '.gitignore'], { cwd });
132
+ } catch {
133
+ // Bỏ qua lỗi ghi file
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ /** Kiểm tra còn gì để commit sau khi auto-fix không */
141
+ export function hasStagedChanges(cwd = process.cwd()) {
142
+ try {
143
+ const r = spawnSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
144
+ if (r.status !== 0) return false;
145
+ return (r.stdout || '').trim().length > 0;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Quét các file secret phổ biến trong thư mục làm việc nhưng chưa được ignore trong .gitignore
153
+ */
154
+ export function findRiskyWorkingTreeFiles(cwd = process.cwd()) {
155
+ const denyNames = [
156
+ '.env', '.env.local', '.env.development', '.env.production',
157
+ '.npmrc', 'secrets.json', 'secret.json', 'credentials.json',
158
+ 'id_rsa', 'id_ed25519'
159
+ ];
160
+ const found = [];
161
+ for (const name of denyNames) {
162
+ const fullPath = path.join(cwd, name);
163
+ if (fs.existsSync(fullPath)) {
164
+ const r = spawnSync('git', ['check-ignore', '-q', name], { cwd });
165
+ if (r.status !== 0) {
166
+ found.push(name);
167
+ }
168
+ }
169
+ }
170
+ return found;
171
+ }
172
+
173
+ /**
174
+ * Tìm upstream ref của branch hiện tại (VD: origin/main hoặc origin/master)
175
+ */
176
+ export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
177
+ const cleanBranch = String(branch || 'main').trim() || 'main';
178
+ // Chặn ký tự shell/git nguy hiểm trước khi truyền (dù đã dùng spawnSync mảng args)
179
+ if (/[\s~^:?*\[\\@{]/.test(cleanBranch) || cleanBranch.includes('..')) return null;
180
+ try {
181
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', `${cleanBranch}@{u}`], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
182
+ if (r.status === 0) {
183
+ const upstream = (r.stdout || '').trim();
184
+ if (upstream) return upstream;
185
+ }
186
+ } catch {}
187
+
188
+ const candidates = [`origin/${cleanBranch}`, 'origin/main', 'origin/master'];
189
+ for (const ref of candidates) {
190
+ try {
191
+ const r = spawnSync('git', ['rev-parse', '--verify', ref], { cwd, stdio: 'ignore' });
192
+ if (r.status === 0) return ref;
193
+ } catch {}
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Lấy danh sách các commit unpushed (chưa đẩy lên remote)
200
+ */
201
+ export function getUnpushedCommits(branch = 'main', cwd = process.cwd()) {
202
+ const upstream = getUpstreamRef(branch, cwd);
203
+ if (!upstream) {
204
+ try {
205
+ const r = spawnSync('git', ['log', '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
206
+ if (r.status !== 0) return [];
207
+ return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+ try {
213
+ const r = spawnSync('git', ['log', `${upstream}..HEAD`, '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
214
+ if (r.status !== 0) return [];
215
+ return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Squash các commit chưa push thành 1 commit duy nhất sạch sẽ,
223
+ * loại bỏ hoàn toàn dấu vết secret ở các commit trung gian (ví dụ commit cũ chứa .npmrc hay .env).
224
+ */
225
+ export function squashUnpushedCommits(branch = 'main', commitMsg = 'Clean commit without secrets', dateFormatted = null, cwd = process.cwd()) {
226
+ const upstream = getUpstreamRef(branch, cwd);
227
+ const usedAmendForRoot = { value: false };
228
+ try {
229
+ if (upstream) {
230
+ const r1 = spawnSync('git', ['reset', '--soft', upstream], { cwd, encoding: 'utf-8' });
231
+ if (r1.status !== 0) {
232
+ throw new Error(String(r1.stderr || r1.stdout || 'Không thể reset về upstream').trim());
233
+ }
234
+ } else {
235
+ // Chưa có upstream (repo mới hoàn toàn): squash TẤT CẢ thành 1 commit duy nhất
236
+ // bằng reset --soft về root rồi --amend root (thay vì commit mới gây ra 2 commits).
237
+ const rRoot = spawnSync('git', ['rev-list', '--max-parents=0', 'HEAD'], { cwd, encoding: 'utf-8' });
238
+ if (rRoot.status !== 0) throw new Error('Không đọc được root commit.');
239
+ const rootCommit = (rRoot.stdout || '').trim().split(/\s+/)[0];
240
+ if (!rootCommit) throw new Error('Không tìm thấy root commit.');
241
+ const rReset = spawnSync('git', ['reset', '--soft', rootCommit], { cwd, encoding: 'utf-8' });
242
+ if (rReset.status !== 0) throw new Error('Không thể reset về root commit.');
243
+ usedAmendForRoot.value = true;
244
+ }
245
+
246
+ // Quét lại và loại bỏ bất kỳ secret file nào còn sót trong staged
247
+ const risky = findRiskyStagedFiles(cwd);
248
+ const findings = scanStagedContentForSecrets(cwd);
249
+ const allSecretFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
250
+ if (allSecretFiles.length > 0) {
251
+ autoFixStagedSecrets(allSecretFiles, cwd);
252
+ }
253
+
254
+ // Đảm bảo .gitignore được stage (nếu tồn tại)
255
+ spawnSync('git', ['add', '.gitignore'], { cwd });
256
+
257
+ if (!hasStagedChanges(cwd) && !usedAmendForRoot.value) {
258
+ return { ok: false, error: 'Không còn thay đổi nào để squash thành commit mới.' };
259
+ }
260
+
261
+ // Tạo commit mới sạch
262
+ const env = { ...process.env };
263
+ if (dateFormatted) {
264
+ env.GIT_AUTHOR_DATE = dateFormatted;
265
+ env.GIT_COMMITTER_DATE = dateFormatted;
266
+ }
267
+ const safeMsg = String(commitMsg || 'Clean commit without secrets');
268
+ let rCommit;
269
+ if (usedAmendForRoot.value) {
270
+ // Amend root để giữ đúng 1 commit duy nhất cho repo chưa có upstream
271
+ rCommit = spawnSync('git', ['commit', '--amend', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
272
+ } else {
273
+ rCommit = spawnSync('git', ['commit', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
274
+ }
275
+ if (rCommit.status !== 0) {
276
+ const msg = String(rCommit.stderr || rCommit.stdout || '').trim();
277
+ if (msg.toLowerCase().includes('nothing to commit')) {
278
+ return { ok: false, error: 'Không có gì để commit sau khi làm sạch (working tree sạch).' };
279
+ }
280
+ throw new Error(msg || 'Lỗi khi tạo commit squash');
281
+ }
282
+ return { ok: true };
283
+ } catch (err) {
284
+ return { ok: false, error: err.message };
285
+ }
286
+ }