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/.github/workflows/npm-publish.yml +33 -0
- package/README.md +215 -134
- package/bin/index.js +337 -1004
- package/lib/commands/edit-time.js +141 -0
- package/lib/commands/fix.js +113 -0
- package/lib/commands/github.js +439 -0
- package/lib/commands/pull.js +89 -0
- package/lib/commands/push.js +673 -0
- package/lib/config.js +220 -0
- package/lib/fix.js +286 -260
- package/lib/git.js +492 -452
- package/lib/github.js +322 -0
- package/lib/prompt-helpers.js +62 -0
- package/lib/secrets.js +92 -87
- package/lib/ui.js +804 -728
- package/lib/utils.js +120 -106
- package/package.json +35 -32
package/lib/fix.js
CHANGED
|
@@ -1,260 +1,286 @@
|
|
|
1
|
-
import { spawnSync
|
|
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 });
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
'.
|
|
157
|
-
'
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
}
|