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/fix.js CHANGED
@@ -1,286 +1,286 @@
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
- }
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
+ * Source-code and documentation file extensions.
9
+ * Never add these files to .gitignore automatically; that could hide project code.
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
+ * Safely remediate detected secrets:
28
+ * 1. git rm --cached -r -f <file> (remove from Git while keeping the local file)
29
+ * 2. Add a matching .gitignore rule (only for secret config files, never source code)
30
+ * 3. Stage .gitignore so the rule is ready to commit
31
+ *
32
+ * @param {string[]} files Files to unstage (e.g. ['.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. Try to unstage with 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
+ // Fall back to git restore --staged if git rm cannot match the path.
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 || 'Could not unstage file' });
74
+ }
75
+ }
76
+
77
+ // 2. Add .gitignore rules for secret config files (skip source files).
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
+ // Unstage source files containing secrets, but do not add them to .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
+ // Stage .gitignore so the new rules are not missed when committing.
131
+ spawnSync('git', ['add', '.gitignore'], { cwd });
132
+ } catch {
133
+ // Ignore file-write errors.
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ /** Check whether anything remains to commit after auto-fix. */
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
+ * Find common secret files in the working directory that are not ignored by .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
+ * Find the upstream ref for the current branch (e.g. origin/main or origin/master).
175
+ */
176
+ export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
177
+ const cleanBranch = String(branch || 'main').trim() || 'main';
178
+ // Reject unsafe Git ref characters before passing the value to Git.
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
+ * Get the list of unpushed commits.
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 unpushed commits into one clean commit, removing secrets from
223
+ * intermediate commits (for example, an earlier commit containing .npmrc or .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 || 'Could not reset to upstream').trim());
233
+ }
234
+ } else {
235
+ // Without an upstream (a new repository), squash everything into one commit
236
+ // by soft-resetting to the root and amending it instead of creating a second commit.
237
+ const rRoot = spawnSync('git', ['rev-list', '--max-parents=0', 'HEAD'], { cwd, encoding: 'utf-8' });
238
+ if (rRoot.status !== 0) throw new Error('Could not read the root commit.');
239
+ const rootCommit = (rRoot.stdout || '').trim().split(/\s+/)[0];
240
+ if (!rootCommit) throw new Error('Root commit not found.');
241
+ const rReset = spawnSync('git', ['reset', '--soft', rootCommit], { cwd, encoding: 'utf-8' });
242
+ if (rReset.status !== 0) throw new Error('Could not reset to the root commit.');
243
+ usedAmendForRoot.value = true;
244
+ }
245
+
246
+ // Scan again and remove any secret files still 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
+ // Ensure .gitignore is staged, if it exists.
255
+ spawnSync('git', ['add', '.gitignore'], { cwd });
256
+
257
+ if (!hasStagedChanges(cwd) && !usedAmendForRoot.value) {
258
+ return { ok: false, error: 'No changes remain to squash into a new commit.' };
259
+ }
260
+
261
+ // Create a new clean commit.
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 the root to keep a single commit when there is no 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: 'Nothing to commit after cleanup (working tree is clean).' };
279
+ }
280
+ throw new Error(msg || 'Could not create the squashed commit');
281
+ }
282
+ return { ok: true };
283
+ } catch (err) {
284
+ return { ok: false, error: err.message };
285
+ }
286
+ }