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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { select } from '@inquirer/prompts';
|
|
7
|
+
import { isValidGitUrl, parseCustomDate } from '../utils.js';
|
|
8
|
+
import {
|
|
9
|
+
isGitRepo,
|
|
10
|
+
initGitRepo,
|
|
11
|
+
getGitTopLevel,
|
|
12
|
+
listSubdirectories,
|
|
13
|
+
resolveTargetDir,
|
|
14
|
+
setGitRemote,
|
|
15
|
+
stageAllFiles,
|
|
16
|
+
hasChangesToCommit,
|
|
17
|
+
commitWithCustomDate,
|
|
18
|
+
setBranchName,
|
|
19
|
+
isValidBranchName,
|
|
20
|
+
pushToRemote,
|
|
21
|
+
getCurrentBranch,
|
|
22
|
+
findRiskyStagedFiles,
|
|
23
|
+
pullFromRemote
|
|
24
|
+
} from '../git.js';
|
|
25
|
+
import { scanStagedContentForSecrets } from '../secrets.js';
|
|
26
|
+
import {
|
|
27
|
+
autoFixStagedSecrets,
|
|
28
|
+
hasStagedChanges,
|
|
29
|
+
getUnpushedCommits,
|
|
30
|
+
squashUnpushedCommits
|
|
31
|
+
} from '../fix.js';
|
|
32
|
+
import {
|
|
33
|
+
getCurrentGitHubUser,
|
|
34
|
+
listUserRepos
|
|
35
|
+
} from '../github.js';
|
|
36
|
+
import { promptCreateRepo, promptSelectGitHubAccount } from './github.js';
|
|
37
|
+
import { selectWithBack, inputWithBack, promptContinueOrBack, BACK } from '../prompt-helpers.js';
|
|
38
|
+
import {
|
|
39
|
+
printSummary,
|
|
40
|
+
printSection,
|
|
41
|
+
stepLabel,
|
|
42
|
+
printTargetDir,
|
|
43
|
+
printNestedRepoWarning,
|
|
44
|
+
printPrePushSecretWarning,
|
|
45
|
+
printSecretBlockedBox,
|
|
46
|
+
printSuccessBox,
|
|
47
|
+
printCancelled,
|
|
48
|
+
printErrorBox,
|
|
49
|
+
printAuthFailedBox,
|
|
50
|
+
printNonFastForwardBox,
|
|
51
|
+
printFixResult,
|
|
52
|
+
printFooter
|
|
53
|
+
} from '../ui.js';
|
|
54
|
+
|
|
55
|
+
const TOTAL_STEPS = 6;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cho user chọn folder nguồn cần push
|
|
59
|
+
*/
|
|
60
|
+
export async function promptForTargetDir(startDir) {
|
|
61
|
+
const subs = listSubdirectories(startDir);
|
|
62
|
+
if (subs.length === 0) return startDir;
|
|
63
|
+
|
|
64
|
+
printSection('📁 1. CHỌN FOLDER DỰ ÁN (PROJECT)');
|
|
65
|
+
printTargetDir(startDir);
|
|
66
|
+
|
|
67
|
+
const picked = await selectWithBack({
|
|
68
|
+
message: 'Thư mục nào sẽ được đẩy lên Git?',
|
|
69
|
+
pageSize: Math.min(12, subs.length + 3),
|
|
70
|
+
choices: [
|
|
71
|
+
{ name: 'Thư mục hiện tại (.)', value: startDir, description: 'Push toàn bộ thư mục hiện tại' },
|
|
72
|
+
...subs.map((s) => ({
|
|
73
|
+
name: `${s.name}`,
|
|
74
|
+
value: s.fullPath,
|
|
75
|
+
description: s.fullPath
|
|
76
|
+
})),
|
|
77
|
+
{ name: 'Nhập đường dẫn khác...', value: '__custom__', description: 'Gõ tay đường dẫn tuyệt đối hoặc tương đối' }
|
|
78
|
+
]
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (picked === BACK) return BACK;
|
|
82
|
+
if (picked !== '__custom__') return picked;
|
|
83
|
+
|
|
84
|
+
const customPath = await inputWithBack({
|
|
85
|
+
message: 'Nhập đường dẫn folder nguồn:',
|
|
86
|
+
validate: (value) => {
|
|
87
|
+
const r = resolveTargetDir(value, startDir);
|
|
88
|
+
return r.ok || r.error;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (customPath === BACK) return BACK;
|
|
92
|
+
return resolveTargetDir(customPath, startDir).fullPath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Xử lý khi thư mục nằm lồng trong repo cha
|
|
97
|
+
*/
|
|
98
|
+
export async function handleNestedRepo(targetDir, isInteractive, options) {
|
|
99
|
+
const topLevel = getGitTopLevel(targetDir);
|
|
100
|
+
if (!topLevel) return;
|
|
101
|
+
if (path.resolve(topLevel) === path.resolve(targetDir)) return;
|
|
102
|
+
if (fs.existsSync(path.join(targetDir, '.git'))) return;
|
|
103
|
+
|
|
104
|
+
printNestedRepoWarning({ targetDir, topLevel });
|
|
105
|
+
|
|
106
|
+
let choice = 'separate';
|
|
107
|
+
if (isInteractive && !options.yes) {
|
|
108
|
+
choice = await selectWithBack({
|
|
109
|
+
message: 'Bạn muốn push thế nào?',
|
|
110
|
+
choices: [
|
|
111
|
+
{ name: 'Tạo repo RIÊNG chỉ cho folder này (khuyên dùng)', value: 'separate', description: 'git init trong folder này, chỉ up nội dung của nó' },
|
|
112
|
+
{ name: 'Dùng chung repo của folder cha', value: 'shared', description: 'Giữ nguyên, push theo repo cha' },
|
|
113
|
+
{ name: 'x Hủy để kiểm tra lại', value: 'abort', description: 'Thoát, không làm gì' }
|
|
114
|
+
]
|
|
115
|
+
});
|
|
116
|
+
if (choice === BACK) return BACK;
|
|
117
|
+
} else if (options.dir) {
|
|
118
|
+
choice = 'separate';
|
|
119
|
+
} else {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (choice === 'abort') {
|
|
124
|
+
printCancelled();
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
if (choice === 'separate') {
|
|
128
|
+
initGitRepo(targetDir);
|
|
129
|
+
console.log(chalk.hex('#34D399')(' [OK] Đã tạo Git repo riêng cho folder này.\n'));
|
|
130
|
+
} else {
|
|
131
|
+
console.log(chalk.hex('#64748B')(' » Dùng chung repo cha (chỉ stage file trong folder này).\n'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Prompt chọn hoặc tạo repository URL
|
|
137
|
+
*/
|
|
138
|
+
async function promptForRepoSelection(targetDir, gitHubUser = null) {
|
|
139
|
+
printSection('📦 3. CHỌN REPOSITORY');
|
|
140
|
+
|
|
141
|
+
const choices = [];
|
|
142
|
+
|
|
143
|
+
if (gitHubUser) {
|
|
144
|
+
choices.push(
|
|
145
|
+
{
|
|
146
|
+
name: `Chọn từ danh sách Repo của @${gitHubUser.login}`,
|
|
147
|
+
value: 'pick_existing',
|
|
148
|
+
description: 'Chọn nhanh từ các repo gần nhất của bạn trên GitHub'
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'Tạo repository MỚI trên GitHub ngay lập tức',
|
|
152
|
+
value: 'create_repo',
|
|
153
|
+
description: `Tự động tạo repo mới trên tài khoản @${gitHubUser.login}`
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
} else {
|
|
157
|
+
choices.push(
|
|
158
|
+
{
|
|
159
|
+
name: 'Tạo repository MỚI trên GitHub',
|
|
160
|
+
value: 'create_repo',
|
|
161
|
+
description: 'Đăng nhập và tạo repository mới'
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
choices.push({
|
|
167
|
+
name: 'Dán URL Git Repository thủ công',
|
|
168
|
+
value: 'manual_url',
|
|
169
|
+
description: 'Dán đường dẫn HTTPS hoặc SSH (GitHub, GitLab, Bitbucket...)'
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const method = await selectWithBack({
|
|
173
|
+
message: 'Bạn muốn chọn repository bằng cách nào?',
|
|
174
|
+
choices
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (method === BACK) return BACK;
|
|
178
|
+
|
|
179
|
+
if (method === 'create_repo') {
|
|
180
|
+
const res = await promptCreateRepo({ targetDir });
|
|
181
|
+
if (!res.ok || !res.repo) return BACK;
|
|
182
|
+
return res.repo.cloneUrl;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (method === 'pick_existing') {
|
|
186
|
+
const spinner = ora({ text: 'Đang tải danh sách repo từ GitHub…', color: 'cyan' }).start();
|
|
187
|
+
const res = await listUserRepos({ limit: 25 });
|
|
188
|
+
spinner.stop();
|
|
189
|
+
|
|
190
|
+
if (!res.ok || !res.repos || res.repos.length === 0) {
|
|
191
|
+
console.log(chalk.hex('#F59E0B')(' [!] Không tìm thấy repo nào trên tài khoản này.'));
|
|
192
|
+
} else {
|
|
193
|
+
const repoChoices = res.repos.map((r) => ({
|
|
194
|
+
name: `${r.private ? chalk.hex('#F59E0B')('[Private]') : chalk.hex('#10B981')('[Public ]')} ${chalk.hex('#F8FAFC').bold(r.name)}`,
|
|
195
|
+
value: r.clone_url,
|
|
196
|
+
description: r.clone_url
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
const picked = await selectWithBack({
|
|
200
|
+
message: 'Chọn repository bạn muốn push lên:',
|
|
201
|
+
pageSize: Math.min(12, repoChoices.length + 2),
|
|
202
|
+
choices: repoChoices
|
|
203
|
+
});
|
|
204
|
+
if (picked !== BACK) return picked;
|
|
205
|
+
return BACK;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Dán URL thủ công
|
|
210
|
+
const val = await inputWithBack({
|
|
211
|
+
message: 'Dán link Git Repository:',
|
|
212
|
+
validate: (value) => {
|
|
213
|
+
if (!value || !value.trim()) return 'Vui lòng nhập đường dẫn repository!';
|
|
214
|
+
if (!isValidGitUrl(value)) return 'URL chưa đúng (vd: https://github.com/user/repo.git)';
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return val;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Thực thi luồng Push chuẩn 7 bước
|
|
224
|
+
*/
|
|
225
|
+
export async function runPushWizard(options = {}, onBackToMenu = null) {
|
|
226
|
+
const isInteractive = !options.yes;
|
|
227
|
+
const fromMenu = typeof onBackToMenu === 'function';
|
|
228
|
+
const startDir = process.cwd();
|
|
229
|
+
// Copy flags sang object mutable để Back-navigation có thể xóa flag đã consume
|
|
230
|
+
// (tránh loop vô hạn khi --repo/--branch/--message/--date còn truthy).
|
|
231
|
+
const eff = { ...options };
|
|
232
|
+
|
|
233
|
+
let targetDir = startDir;
|
|
234
|
+
let selectedGitHubUser = null;
|
|
235
|
+
let repoUrl = eff.repo;
|
|
236
|
+
let branchName = eff.branch;
|
|
237
|
+
let commitMsg = eff.message;
|
|
238
|
+
let dateInput = eff.date;
|
|
239
|
+
let parsedDate = null;
|
|
240
|
+
|
|
241
|
+
if (eff.dir) {
|
|
242
|
+
const r = resolveTargetDir(eff.dir, startDir);
|
|
243
|
+
if (!r.ok) {
|
|
244
|
+
printErrorBox('THƯ MỤC NGUỒN KHÔNG HỢP LỆ', [r.error], 'VD: --dir ./my-app hoặc --dir "E:/projects/my-app"');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
targetDir = r.fullPath;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!isInteractive) {
|
|
251
|
+
if (!commitMsg) commitMsg = 'Initial commit';
|
|
252
|
+
if (!dateInput) dateInput = 'now';
|
|
253
|
+
if (!branchName) branchName = 'main';
|
|
254
|
+
if (!repoUrl || !isValidGitUrl(repoUrl)) {
|
|
255
|
+
printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
parsedDate = parseCustomDate(dateInput);
|
|
259
|
+
if (!parsedDate.valid) {
|
|
260
|
+
printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
|
|
264
|
+
} else {
|
|
265
|
+
// Luồng tương tác 7 bước chuẩn:
|
|
266
|
+
// 0: 📁 Chọn folder
|
|
267
|
+
// 1: 👤 Chọn GitHub account
|
|
268
|
+
// 2: 📦 Chọn repository
|
|
269
|
+
// 3: 🌿 Chọn branch
|
|
270
|
+
// 4: 💬 Nhập commit message
|
|
271
|
+
// 5: ⏰ Chọn commit time
|
|
272
|
+
// 6: 🚀 Xác nhận & Push
|
|
273
|
+
let step = eff.dir ? 1 : 0;
|
|
274
|
+
|
|
275
|
+
// Nếu đã truyền sẵn URL qua flag thì có thể nhảy bước chọn repo (chỉ consume 1 lần)
|
|
276
|
+
if (eff.repo) {
|
|
277
|
+
repoUrl = eff.repo;
|
|
278
|
+
delete eff.repo;
|
|
279
|
+
step = 3;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
while (true) {
|
|
283
|
+
// BƯỚC 0: 📁 Chọn folder project
|
|
284
|
+
if (step === 0) {
|
|
285
|
+
const picked = await promptForTargetDir(startDir);
|
|
286
|
+
if (picked === BACK) {
|
|
287
|
+
if (onBackToMenu) return await onBackToMenu();
|
|
288
|
+
printCancelled();
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
targetDir = picked;
|
|
292
|
+
delete eff.dir;
|
|
293
|
+
const nested = await handleNestedRepo(targetDir, isInteractive, eff);
|
|
294
|
+
if (nested === BACK) continue;
|
|
295
|
+
step = 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// BƯỚC 1: 👤 Chọn GitHub account
|
|
300
|
+
if (step === 1) {
|
|
301
|
+
const accRes = await promptSelectGitHubAccount();
|
|
302
|
+
if (accRes.back) {
|
|
303
|
+
// Cho phép quay về chọn folder dù ban đầu có --dir
|
|
304
|
+
delete eff.dir;
|
|
305
|
+
step = 0;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
selectedGitHubUser = accRes.useGitHub ? accRes.user : null;
|
|
309
|
+
step = 2;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// BƯỚC 2: 📦 Chọn repository
|
|
314
|
+
if (step === 2) {
|
|
315
|
+
if (eff.repo) {
|
|
316
|
+
repoUrl = eff.repo;
|
|
317
|
+
delete eff.repo;
|
|
318
|
+
step = 3;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const pickedUrl = await promptForRepoSelection(targetDir, selectedGitHubUser);
|
|
322
|
+
if (pickedUrl === BACK) {
|
|
323
|
+
step = 1;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
repoUrl = pickedUrl;
|
|
327
|
+
if (!repoUrl || !isValidGitUrl(repoUrl)) {
|
|
328
|
+
printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL: '${repoUrl || '(trống)'}'`]);
|
|
329
|
+
const retry = await promptContinueOrBack('URL chưa hợp lệ. Bạn muốn thử lại hay quay lại?');
|
|
330
|
+
if (retry === 'exit') { printCancelled(); process.exit(0); }
|
|
331
|
+
if (retry === 'back') step = 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
step = 3;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// BƯỚC 3: 🌿 Chọn branch
|
|
339
|
+
if (step === 3) {
|
|
340
|
+
if (eff.branch) {
|
|
341
|
+
branchName = eff.branch;
|
|
342
|
+
delete eff.branch;
|
|
343
|
+
step = 4;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
printSection('🌿 4. CHỌN BRANCH REMOTE');
|
|
347
|
+
const currentB = getCurrentBranch(targetDir);
|
|
348
|
+
const val = await inputWithBack({
|
|
349
|
+
message: 'Tên branch trên remote:',
|
|
350
|
+
default: currentB || 'main',
|
|
351
|
+
validate: (v) => {
|
|
352
|
+
if (!v || !v.trim()) return true;
|
|
353
|
+
return isValidBranchName(v.trim()) || 'Tên branch chứa ký tự không hợp lệ (cấm space, ~ ^ : ? * [ \\ ..)';
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
if (val === BACK) {
|
|
357
|
+
step = 2;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
branchName = val || currentB || 'main';
|
|
361
|
+
if (!isValidBranchName(branchName)) {
|
|
362
|
+
printErrorBox('BRANCH KHÔNG HỢP LỆ', [`Tên branch: '${branchName}'`]);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
step = 4;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// BƯỚC 4: 💬 Nhập commit message
|
|
370
|
+
if (step === 4) {
|
|
371
|
+
if (eff.message) {
|
|
372
|
+
commitMsg = eff.message;
|
|
373
|
+
delete eff.message;
|
|
374
|
+
step = 5;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
printSection('💬 5. NHẬP COMMIT MESSAGE');
|
|
378
|
+
const val = await inputWithBack({
|
|
379
|
+
message: 'Thông điệp commit (Message):',
|
|
380
|
+
default: 'Initial commit'
|
|
381
|
+
});
|
|
382
|
+
if (val === BACK) {
|
|
383
|
+
step = 3;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
commitMsg = val || 'Initial commit';
|
|
387
|
+
step = 5;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// BƯỚC 5: ⏰ Chọn commit time
|
|
392
|
+
if (step === 5) {
|
|
393
|
+
if (eff.date) {
|
|
394
|
+
dateInput = eff.date;
|
|
395
|
+
delete eff.date;
|
|
396
|
+
step = 6;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
printSection('⏰ 6. CHỌN THỜI GIAN COMMIT');
|
|
400
|
+
const dateOption = await selectWithBack({
|
|
401
|
+
message: 'Thời gian cho commit này:',
|
|
402
|
+
choices: [
|
|
403
|
+
{ name: 'Ngay bây giờ', value: 'now', description: 'Sử dụng thời gian hiện tại' },
|
|
404
|
+
{ name: 'Lùi thời gian (Backdate)', value: 'relative', description: 'VD: -2d (2 ngày trước), -5h, -30m, -1w' },
|
|
405
|
+
{ name: 'Ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
|
|
406
|
+
]
|
|
407
|
+
});
|
|
408
|
+
if (dateOption === BACK) {
|
|
409
|
+
step = 4;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (dateOption === 'now') {
|
|
414
|
+
dateInput = 'now';
|
|
415
|
+
} else if (dateOption === 'relative') {
|
|
416
|
+
const val = await inputWithBack({
|
|
417
|
+
message: 'Nhập thời gian lùi (VD: -2d, -5h, -30m, -1w):',
|
|
418
|
+
default: '-1d',
|
|
419
|
+
validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Thời gian không hợp lệ'; }
|
|
420
|
+
});
|
|
421
|
+
if (val === BACK) continue;
|
|
422
|
+
dateInput = val;
|
|
423
|
+
} else if (dateOption === 'custom') {
|
|
424
|
+
const val = await inputWithBack({
|
|
425
|
+
message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
|
|
426
|
+
default: '2024-01-15 10:00:00',
|
|
427
|
+
validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Ngày giờ không hợp lệ'; }
|
|
428
|
+
});
|
|
429
|
+
if (val === BACK) continue;
|
|
430
|
+
dateInput = val;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
parsedDate = parseCustomDate(dateInput || 'now');
|
|
434
|
+
if (!parsedDate.valid) {
|
|
435
|
+
printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error]);
|
|
436
|
+
const retry = await promptContinueOrBack('Thời gian chưa đúng. Bạn muốn nhập lại hay quay lại?');
|
|
437
|
+
if (retry === 'exit') { printCancelled(); process.exit(0); }
|
|
438
|
+
dateInput = undefined;
|
|
439
|
+
delete eff.date;
|
|
440
|
+
if (retry === 'back') step = 4;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
step = 6;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// BƯỚC 6: 🚀 Xác nhận & Đẩy lên
|
|
448
|
+
if (step === 6) {
|
|
449
|
+
if (!commitMsg) commitMsg = 'Initial commit';
|
|
450
|
+
if (!dateInput) dateInput = 'now';
|
|
451
|
+
if (!branchName) branchName = 'main';
|
|
452
|
+
parsedDate = parseCustomDate(dateInput);
|
|
453
|
+
|
|
454
|
+
printSummary({
|
|
455
|
+
repoUrl,
|
|
456
|
+
commitMsg,
|
|
457
|
+
dateDisplay: parsedDate.display,
|
|
458
|
+
branch: branchName,
|
|
459
|
+
force: options.force,
|
|
460
|
+
sourceDir: targetDir
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const decision = await select({
|
|
464
|
+
message: 'Xác nhận đẩy dự án lên Git ngay?',
|
|
465
|
+
choices: [
|
|
466
|
+
{ name: 'Bắt đầu đẩy code (Push)', value: 'continue', description: 'Thực thi git init/add/commit/push' },
|
|
467
|
+
{ name: 'Sửa thời gian commit', value: 'back-date', description: 'Về bước chọn thời gian' },
|
|
468
|
+
{ name: 'Sửa commit message', value: 'back-msg', description: 'Về bước nhập message' },
|
|
469
|
+
{ name: 'Sửa branch', value: 'back-branch', description: 'Về bước chọn branch' },
|
|
470
|
+
{ name: 'Sửa repository', value: 'back-repo', description: 'Về bước chọn repository' },
|
|
471
|
+
{ name: 'Sửa tài khoản GitHub', value: 'back-account', description: 'Về bước chọn tài khoản GitHub' },
|
|
472
|
+
{ name: 'Chọn lại folder dự án', value: 'back-folder', description: 'Về bước chọn thư mục' },
|
|
473
|
+
{ name: 'Về menu chính', value: 'back-menu', description: 'Quay lại màn hình điều khiển' },
|
|
474
|
+
{ name: 'x Hủy bỏ', value: 'exit', description: 'Thoát' }
|
|
475
|
+
]
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (decision === 'exit') { printCancelled(); process.exit(0); }
|
|
479
|
+
if (decision === 'continue') break;
|
|
480
|
+
if (decision === 'back-date') { dateInput = undefined; delete eff.date; parsedDate = null; step = 5; continue; }
|
|
481
|
+
if (decision === 'back-msg') { commitMsg = undefined; delete eff.message; step = 4; continue; }
|
|
482
|
+
if (decision === 'back-branch') { branchName = undefined; delete eff.branch; step = 3; continue; }
|
|
483
|
+
if (decision === 'back-repo') { repoUrl = undefined; delete eff.repo; step = 2; continue; }
|
|
484
|
+
if (decision === 'back-account') { step = 1; continue; }
|
|
485
|
+
if (decision === 'back-folder') { delete eff.dir; step = 0; continue; }
|
|
486
|
+
if (decision === 'back-menu') {
|
|
487
|
+
if (onBackToMenu) return await onBackToMenu();
|
|
488
|
+
step = options.dir ? 1 : 0;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 🚀 BƯỚC 7: THỰC THI PUSH
|
|
496
|
+
const spinner = ora({ color: 'cyan' });
|
|
497
|
+
printSection(`🚀 ĐANG ĐẨY CODE LÊN GIT (${TOTAL_STEPS} BƯỚC)`);
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
// 1. Git init
|
|
501
|
+
if (!isGitRepo(targetDir)) {
|
|
502
|
+
spinner.start(stepLabel(1, TOTAL_STEPS, 'Khởi tạo Git repo (git init)...'));
|
|
503
|
+
initGitRepo(targetDir);
|
|
504
|
+
spinner.succeed(stepLabel(1, TOTAL_STEPS, chalk.hex('#34D399')('Đã khởi tạo Git repo')));
|
|
505
|
+
} else {
|
|
506
|
+
console.log(' ' + stepLabel(1, TOTAL_STEPS, chalk.hex('#64748B')('Đã có Git repo sẵn — bỏ qua init')));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 2. Remote
|
|
510
|
+
spinner.start(stepLabel(2, TOTAL_STEPS, 'Cấu hình remote origin...'));
|
|
511
|
+
setGitRemote(repoUrl, targetDir);
|
|
512
|
+
spinner.succeed(stepLabel(2, TOTAL_STEPS, chalk.hex('#34D399')('Cấu hình remote origin hoàn tất')));
|
|
513
|
+
|
|
514
|
+
// 3. Stage
|
|
515
|
+
spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)...'));
|
|
516
|
+
stageAllFiles(targetDir);
|
|
517
|
+
spinner.succeed(stepLabel(3, TOTAL_STEPS, chalk.hex('#34D399')('Đã stage toàn bộ files')));
|
|
518
|
+
|
|
519
|
+
// 3.5. Quét secret nhạy cảm
|
|
520
|
+
const risky = findRiskyStagedFiles(targetDir);
|
|
521
|
+
const findings = scanStagedContentForSecrets(targetDir);
|
|
522
|
+
if (risky.length > 0 || findings.length > 0) {
|
|
523
|
+
spinner.stop();
|
|
524
|
+
printPrePushSecretWarning({ riskyFiles: risky, findings });
|
|
525
|
+
const allFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
|
|
526
|
+
|
|
527
|
+
let wantFix = options.fix === true;
|
|
528
|
+
if (!wantFix && isInteractive) {
|
|
529
|
+
const fixChoice = await selectWithBack({
|
|
530
|
+
message: 'Tự động gỡ file secret an toàn? (giữ nguyên file ở máy, gỡ khỏi commit)',
|
|
531
|
+
choices: [
|
|
532
|
+
{ name: 'Có, tự động gỡ file nhạy cảm', value: 'yes', description: 'Gỡ file secret khỏi stage & thêm .gitignore' },
|
|
533
|
+
{ name: 'Không, giữ nguyên', value: 'no', description: 'Tiếp tục mà không gỡ' }
|
|
534
|
+
]
|
|
535
|
+
});
|
|
536
|
+
if (fixChoice === BACK) {
|
|
537
|
+
printCancelled();
|
|
538
|
+
process.exit(0);
|
|
539
|
+
}
|
|
540
|
+
wantFix = fixChoice === 'yes';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (wantFix) {
|
|
544
|
+
spinner.start('Đang tự fix: gỡ file secret khỏi stage...');
|
|
545
|
+
const res = autoFixStagedSecrets(allFiles, targetDir);
|
|
546
|
+
spinner.succeed(chalk.hex('#34D399')(`[OK] Đã gỡ ${res.fixed.length} file khỏi commit`));
|
|
547
|
+
printFixResult(res);
|
|
548
|
+
|
|
549
|
+
spawnSync('git', ['add', '.gitignore'], { cwd: targetDir, stdio: 'ignore' });
|
|
550
|
+
|
|
551
|
+
if (!hasStagedChanges(targetDir)) {
|
|
552
|
+
printErrorBox('KHÔNG CÒN FILE ĐỂ COMMIT', [
|
|
553
|
+
'Sau khi gỡ secret thì không còn file nào để commit.',
|
|
554
|
+
'File của bạn vẫn còn trên máy, không bị mất.'
|
|
555
|
+
]);
|
|
556
|
+
if (fromMenu) return 'back';
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
console.log(chalk.hex('#34D399')(' » Tiếp tục commit/push phần code an toàn.\n'));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 4. Commit
|
|
564
|
+
spinner.start(stepLabel(4, TOTAL_STEPS, `Tạo commit [${parsedDate.display}]...`));
|
|
565
|
+
if (!hasChangesToCommit(targetDir)) {
|
|
566
|
+
spinner.info(stepLabel(4, TOTAL_STEPS, chalk.hex('#94A3B8')('Không có thay đổi mới — dùng commit hiện có')));
|
|
567
|
+
} else {
|
|
568
|
+
commitWithCustomDate(commitMsg, parsedDate.formatted, targetDir);
|
|
569
|
+
spinner.succeed(stepLabel(4, TOTAL_STEPS, chalk.hex('#34D399')(`Commit hoàn tất • ${chalk.hex('#C084FC').bold(parsedDate.display)}`)));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 5. Branch
|
|
573
|
+
spinner.start(stepLabel(5, TOTAL_STEPS, `Đổi branch -> '${branchName}'...`));
|
|
574
|
+
setBranchName(branchName, targetDir);
|
|
575
|
+
spinner.succeed(stepLabel(5, TOTAL_STEPS, chalk.hex('#34D399')(`Branch: ${chalk.hex('#5EEAD4').bold(branchName)}`)));
|
|
576
|
+
|
|
577
|
+
// 6. Push
|
|
578
|
+
spinner.start(stepLabel(6, TOTAL_STEPS, `Push lên origin/${branchName}...`));
|
|
579
|
+
pushToRemote(branchName, options.force, targetDir);
|
|
580
|
+
spinner.succeed(stepLabel(6, TOTAL_STEPS, chalk.bold.hex('#34D399')('Push lên remote thành công!')));
|
|
581
|
+
|
|
582
|
+
printSuccessBox({ branch: branchName, repoUrl });
|
|
583
|
+
printFooter();
|
|
584
|
+
} catch (error) {
|
|
585
|
+
spinner.stop();
|
|
586
|
+
if (error.code === 'SECRET_BLOCKED') {
|
|
587
|
+
printSecretBlockedBox(error.message, error.details || {});
|
|
588
|
+
|
|
589
|
+
const unpushed = getUnpushedCommits(branchName, targetDir);
|
|
590
|
+
if (unpushed.length > 0) {
|
|
591
|
+
console.log(chalk.cyanBright(`\n » Phát hiện bạn có ${unpushed.length} commit chưa push có thể chứa vết secret cũ.`));
|
|
592
|
+
let doSquash = options.fix === true;
|
|
593
|
+
if (!doSquash && isInteractive) {
|
|
594
|
+
const sqChoice = await selectWithBack({
|
|
595
|
+
message: 'Tự động làm sạch lịch sử (squash các commit thành 1 commit sạch) để push lại?',
|
|
596
|
+
choices: [
|
|
597
|
+
{ name: 'Có, squash và push lại ngay', value: 'yes', description: 'Làm sạch commit' },
|
|
598
|
+
{ name: 'Không, để tự xử lý', value: 'no', description: 'Dừng' }
|
|
599
|
+
]
|
|
600
|
+
});
|
|
601
|
+
doSquash = sqChoice === 'yes';
|
|
602
|
+
}
|
|
603
|
+
if (doSquash) {
|
|
604
|
+
spinner.start('Đang squash và làm sạch commit chưa push...');
|
|
605
|
+
const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted, targetDir);
|
|
606
|
+
if (sqRes.ok) {
|
|
607
|
+
spinner.succeed(chalk.green('[OK] Đã làm sạch commit! Đang thử push lại...'));
|
|
608
|
+
try {
|
|
609
|
+
spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
|
|
610
|
+
pushToRemote(branchName, options.force, targetDir);
|
|
611
|
+
spinner.succeed(chalk.bold.green('[OK] Push thành công'));
|
|
612
|
+
printSuccessBox({ branch: branchName, repoUrl });
|
|
613
|
+
printFooter();
|
|
614
|
+
return;
|
|
615
|
+
} catch (retryErr) {
|
|
616
|
+
spinner.fail(chalk.red(`Push lại thất bại: ${retryErr.message}`));
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
spinner.fail(chalk.red(`Không thể squash: ${sqRes.error}`));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} else if (error.code === 'AUTH_FAILED') {
|
|
624
|
+
printAuthFailedBox(error.message);
|
|
625
|
+
} else if (error.code === 'NON_FAST_FORWARD') {
|
|
626
|
+
printNonFastForwardBox(error.message, branchName);
|
|
627
|
+
if (isInteractive) {
|
|
628
|
+
const ffChoice = await select({
|
|
629
|
+
message: 'Remote đã có commit mới hơn. Bạn muốn xử lý thế nào?',
|
|
630
|
+
choices: [
|
|
631
|
+
{ name: 'Pull (rebase) rồi push lại — Khuyên dùng', value: 'pull-push', description: `git pull --rebase origin ${branchName} rồi push lại` },
|
|
632
|
+
{ name: 'Chỉ Pull về (chưa push vội)', value: 'pull-only', description: 'Chỉ đồng bộ code về máy' },
|
|
633
|
+
{ name: 'Quay lại menu chính', value: 'back', description: 'Về màn hình điều khiển' },
|
|
634
|
+
{ name: 'x Thoát', value: 'exit', description: 'Dừng lại' }
|
|
635
|
+
]
|
|
636
|
+
});
|
|
637
|
+
if (ffChoice === 'pull-push' || ffChoice === 'pull-only') {
|
|
638
|
+
try {
|
|
639
|
+
spinner.start(`Đang pull --rebase origin/${branchName}...`);
|
|
640
|
+
const pullOut = pullFromRemote('origin', branchName, targetDir, { rebase: true });
|
|
641
|
+
spinner.succeed(chalk.hex('#34D399')(`Đã pull --rebase origin/${branchName} xong`));
|
|
642
|
+
if (pullOut) console.log(chalk.hex('#64748B')(` ${String(pullOut).split('\n')[0].slice(0, 120)}`));
|
|
643
|
+
if (ffChoice === 'pull-push') {
|
|
644
|
+
spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
|
|
645
|
+
pushToRemote(branchName, options.force, targetDir);
|
|
646
|
+
spinner.succeed(chalk.bold.hex('#34D399')('Push lại thành công sau khi pull!'));
|
|
647
|
+
printSuccessBox({ branch: branchName, repoUrl });
|
|
648
|
+
printFooter();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
console.log(chalk.hex('#34D399')('\n [OK] Đã pull code mới nhất về.\n'));
|
|
652
|
+
if (fromMenu) return 'back';
|
|
653
|
+
return;
|
|
654
|
+
} catch (pullErr) {
|
|
655
|
+
spinner.fail(chalk.red(`Pull --rebase thất bại: ${String(pullErr.message).split('\n')[0]}`));
|
|
656
|
+
printErrorBox('PULL --REBASE THẤT BẠI', [String(pullErr.message).split('\n')[0]]);
|
|
657
|
+
if (fromMenu) return 'back';
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
} else if (ffChoice === 'back' && onBackToMenu) {
|
|
661
|
+
return await onBackToMenu();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} else if (error.code === 'REMOTE_OR_NETWORK') {
|
|
665
|
+
printErrorBox('LỖI KẾT NỐI REMOTE', [String(error.message).split('\n')[0]], 'Kiểm tra URL repo, mạng, và quyền truy cập.');
|
|
666
|
+
} else {
|
|
667
|
+
printErrorBox('ĐẨY LÊN GIT THẤT BẠI', [error.message]);
|
|
668
|
+
}
|
|
669
|
+
// Khi chạy từ menu tương tác: quay về menu thay vì kill process
|
|
670
|
+
if (fromMenu) return 'back';
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
}
|