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/git.js
CHANGED
|
@@ -1,452 +1,492 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
12
|
-
return
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
*
|
|
133
|
-
* @param {string}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
*
|
|
150
|
-
* @param {string}
|
|
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
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
throw new Error(`Git
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
*
|
|
197
|
-
* @param {string}
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|