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.
@@ -1,720 +1,720 @@
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 { isValidGitUrl, parseCustomDate } from '../utils.js';
7
- import {
8
- isGitRepo,
9
- initGitRepo,
10
- getGitTopLevel,
11
- listSubdirectories,
12
- resolveTargetDir,
13
- setGitRemote,
14
- stageAllFiles,
15
- hasChangesToCommit,
16
- commitWithCustomDate,
17
- setBranchName,
18
- isValidBranchName,
19
- pushToRemote,
20
- getCurrentBranch,
21
- findRiskyStagedFiles,
22
- pullFromRemote
23
- } from '../git.js';
24
- import { scanStagedContentForSecrets } from '../secrets.js';
25
- import {
26
- autoFixStagedSecrets,
27
- hasStagedChanges,
28
- getUnpushedCommits,
29
- squashUnpushedCommits
30
- } from '../fix.js';
31
- import {
32
- getCurrentGitHubUser,
33
- listUserRepos
34
- } from '../github.js';
35
- import { promptCreateRepo, promptSelectGitHubAccount } from './github.js';
36
- import { selectWithBack, inputWithBack, promptContinueOrBack, selectBox, BACK } from '../prompt-helpers.js';
37
- import {
38
- printSummary,
39
- printSection,
40
- printPushProgress,
41
- stepLabel,
42
- printNestedRepoWarning,
43
- printPrePushSecretWarning,
44
- printSecretBlockedBox,
45
- printSuccessBox,
46
- printCancelled,
47
- printErrorBox,
48
- printAuthFailedBox,
49
- printNonFastForwardBox,
50
- printFixResult,
51
- printFooter
52
- } from '../ui.js';
53
-
54
- const TOTAL_STEPS = 6;
55
- const WIZARD_STEPS = 7;
56
-
57
- /**
58
- * Cho user chọn folder nguồn cần push
59
- */
60
- export async function promptForTargetDir(startDir, clearPromptOnDone = false) {
61
- const subs = listSubdirectories(startDir);
62
- if (subs.length === 0) return startDir;
63
-
64
- const picked = await selectWithBack({
65
- message: 'Thư mục nào sẽ được đẩy lên Git?',
66
- clearPromptOnDone,
67
- pageSize: Math.min(12, subs.length + 3),
68
- choices: [
69
- { name: 'Thư mục hiện tại (.)', value: startDir, description: 'Push toàn bộ thư mục hiện tại' },
70
- ...subs.map((s) => ({
71
- name: `${s.name}`,
72
- value: s.fullPath,
73
- description: s.fullPath
74
- })),
75
- { name: 'Nhập đường dẫn khác...', value: '__custom__', description: 'Gõ tay đường dẫn tuyệt đối hoặc tương đối' }
76
- ]
77
- });
78
-
79
- if (picked === BACK) return BACK;
80
- if (picked !== '__custom__') return picked;
81
-
82
- const customPath = await inputWithBack({
83
- message: 'Đường dẫn folder nguồn',
84
- hint: ['Nhập tuyệt đối hoặc tương đối (VD: ./my-app, E:/projects/demo)'],
85
- clearPromptOnDone,
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, clearPromptOnDone = false) {
139
- const choices = [];
140
-
141
- if (gitHubUser) {
142
- choices.push(
143
- {
144
- name: `Chọn từ danh sách Repo của @${gitHubUser.login}`,
145
- value: 'pick_existing',
146
- description: 'Chọn nhanh từ các repo gần nhất của bạn trên GitHub'
147
- },
148
- {
149
- name: 'Tạo repository MỚI trên GitHub ngay lập tức',
150
- value: 'create_repo',
151
- description: `Tự động tạo repo mới trên tài khoản @${gitHubUser.login}`
152
- }
153
- );
154
- } else {
155
- choices.push(
156
- {
157
- name: 'Tạo repository MỚI trên GitHub',
158
- value: 'create_repo',
159
- description: 'Đăng nhập và tạo repository mới'
160
- }
161
- );
162
- }
163
-
164
- choices.push({
165
- name: 'Dán URL Git Repository thủ công',
166
- value: 'manual_url',
167
- description: 'Dán đường dẫn HTTPS hoặc SSH (GitHub, GitLab, Bitbucket...)'
168
- });
169
-
170
- const method = await selectWithBack({
171
- message: 'Bạn muốn chọn repository bằng cách nào?',
172
- clearPromptOnDone,
173
- choices
174
- });
175
-
176
- if (method === BACK) return BACK;
177
-
178
- if (method === 'create_repo') {
179
- const res = await promptCreateRepo({ targetDir });
180
- if (!res.ok || !res.repo) return BACK;
181
- return res.repo.cloneUrl;
182
- }
183
-
184
- if (method === 'pick_existing') {
185
- const spinner = ora({ text: 'Đang tải danh sách repo từ GitHub…', color: 'cyan' }).start();
186
- const res = await listUserRepos({ limit: 25 });
187
- spinner.stop();
188
-
189
- if (!res.ok || !res.repos || res.repos.length === 0) {
190
- console.log(chalk.hex('#F59E0B')(' [!] Không tìm thấy repo nào trên tài khoản này.'));
191
- } else {
192
- const repoChoices = res.repos.map((r) => ({
193
- name: `${r.private ? chalk.hex('#F59E0B')('[Private]') : chalk.hex('#10B981')('[Public ]')} ${chalk.hex('#F8FAFC').bold(r.name)}`,
194
- value: r.clone_url,
195
- description: r.clone_url
196
- }));
197
-
198
- const picked = await selectWithBack({
199
- message: 'Chọn repository bạn muốn push lên:',
200
- clearPromptOnDone,
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: 'Link Git Repository',
212
- hint: ['HTTPS hoặc SSH (VD: https://github.com/user/repo.git)'],
213
- clearPromptOnDone,
214
- validate: (value) => {
215
- if (!value || !value.trim()) return 'Vui lòng nhập đường dẫn repository!';
216
- if (!isValidGitUrl(value)) return 'URL chưa đúng (vd: https://github.com/user/repo.git)';
217
- return true;
218
- }
219
- });
220
-
221
- return val;
222
- }
223
-
224
- /**
225
- * Thực thi luồng Push chuẩn 7 bước
226
- */
227
- export async function runPushWizard(options = {}, onBackToMenu = null) {
228
- const isInteractive = !options.yes;
229
- const fromMenu = typeof onBackToMenu === 'function';
230
- const startDir = process.cwd();
231
- // Copy flags sang object mutable để Back-navigation có thể xóa flag đã consume
232
- // (tránh loop vô hạn khi --repo/--branch/--message/--date còn truthy).
233
- const eff = { ...options };
234
-
235
- let targetDir = startDir;
236
- let selectedGitHubUser = null;
237
- let repoUrl = eff.repo;
238
- let branchName = eff.branch ? String(eff.branch).trim() : undefined;
239
- let commitMsg = eff.message ? String(eff.message).trim() : undefined;
240
- let dateInput = eff.date;
241
- let parsedDate = null;
242
-
243
- if (eff.dir) {
244
- const r = resolveTargetDir(eff.dir, startDir);
245
- if (!r.ok) {
246
- printErrorBox('THƯ MỤC NGUỒN KHÔNG HỢP LỆ', [r.error], 'VD: --dir ./my-app hoặc --dir "E:/projects/my-app"');
247
- process.exit(1);
248
- }
249
- targetDir = r.fullPath;
250
- }
251
-
252
- if (!isInteractive) {
253
- if (!commitMsg) commitMsg = 'Initial commit';
254
- if (!dateInput) dateInput = 'now';
255
- if (!branchName) branchName = 'main';
256
- if (!repoUrl || !isValidGitUrl(repoUrl)) {
257
- printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
258
- process.exit(1);
259
- }
260
- parsedDate = parseCustomDate(dateInput);
261
- if (!parsedDate.valid) {
262
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
263
- process.exit(1);
264
- }
265
- printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
266
- } else {
267
- // Luồng tương tác 7 bước chuẩn:
268
- // 0: 📁 Chọn folder
269
- // 1: 👤 Chọn GitHub account
270
- // 2: 📦 Chọn repository
271
- // 3: 🌿 Chọn branch
272
- // 4: 💬 Nhập commit message
273
- // 5: ⏰ Chọn commit time
274
- // 6: 🚀 Xác nhận & Push
275
- let step = eff.dir ? 1 : 0;
276
-
277
- // Nếu đã truyền sẵn URL qua flag thì có thể nhảy bước chọn repo (chỉ consume 1 lần)
278
- if (eff.repo) {
279
- repoUrl = eff.repo;
280
- delete eff.repo;
281
- step = 3;
282
- }
283
-
284
- while (true) {
285
- const progressTitles = ['Folder', 'Account', 'Repository', 'Branch', 'Message', 'Time', 'Xác nhận'];
286
- const completed = [];
287
- if (step > 0) completed.push({ label: 'Folder', value: targetDir });
288
- if (step > 1) completed.push({ label: 'Account', value: selectedGitHubUser?.login || 'URL thủ công' });
289
- if (step > 2) {
290
- const shortRepo = String(repoUrl || '').replace(/\.git$/i, '').match(/(?:github\.com[:/])([^/]+\/[^/]+)$/i)?.[1] || repoUrl;
291
- completed.push({ label: 'Repository', value: shortRepo || 'Đã chọn' });
292
- }
293
- if (step > 3) completed.push({ label: 'Branch', value: branchName || 'main' });
294
- if (step > 4) completed.push({ label: 'Message', value: commitMsg || 'Initial commit' });
295
- if (step > 5) completed.push({ label: 'Time', value: parsedDate?.display || dateInput || 'now' });
296
- printPushProgress({ current: step + 1, total: WIZARD_STEPS, title: progressTitles[step], completed });
297
-
298
- // BƯỚC 0: 📁 Chọn folder project
299
- if (step === 0) {
300
- const picked = await promptForTargetDir(startDir, true);
301
- if (picked === BACK) {
302
- if (onBackToMenu) return await onBackToMenu();
303
- printCancelled();
304
- process.exit(0);
305
- }
306
- targetDir = picked;
307
- delete eff.dir;
308
- const nested = await handleNestedRepo(targetDir, isInteractive, eff);
309
- if (nested === BACK) continue;
310
- step = 1;
311
- continue;
312
- }
313
-
314
- // BƯỚC 1: 👤 Chọn GitHub account
315
- if (step === 1) {
316
- const accRes = await promptSelectGitHubAccount({ clearPromptOnDone: true });
317
- if (accRes.back) {
318
- // Cho phép quay về chọn folder dù ban đầu có --dir
319
- delete eff.dir;
320
- step = 0;
321
- continue;
322
- }
323
- selectedGitHubUser = accRes.useGitHub ? accRes.user : null;
324
- step = 2;
325
- continue;
326
- }
327
-
328
- // BƯỚC 2: 📦 Chọn repository
329
- if (step === 2) {
330
- if (eff.repo) {
331
- repoUrl = eff.repo;
332
- delete eff.repo;
333
- step = 3;
334
- continue;
335
- }
336
- const pickedUrl = await promptForRepoSelection(targetDir, selectedGitHubUser, true);
337
- if (pickedUrl === BACK) {
338
- step = 1;
339
- continue;
340
- }
341
- repoUrl = pickedUrl;
342
- if (!repoUrl || !isValidGitUrl(repoUrl)) {
343
- printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL: '${repoUrl || '(trống)'}'`]);
344
- const retry = await promptContinueOrBack('URL chưa hợp lệ. Bạn muốn thử lại hay quay lại?');
345
- if (retry === 'exit') { printCancelled(); process.exit(0); }
346
- if (retry === 'back') step = 1;
347
- continue;
348
- }
349
- step = 3;
350
- continue;
351
- }
352
-
353
- // BƯỚC 3: 🌿 Chọn branch
354
- if (step === 3) {
355
- if (eff.branch) {
356
- branchName = String(eff.branch).trim();
357
- delete eff.branch;
358
- step = 4;
359
- continue;
360
- }
361
- const currentB = getCurrentBranch(targetDir);
362
- const val = await inputWithBack({
363
- message: 'Đặt tên nhánh Git',
364
- promptLabel: 'Tên nhánh',
365
- hint: [
366
- 'Nhánh local sẽ được đổi tên này trước khi push lên origin.',
367
- 'Ví dụ: main, dev, feature/login.',
368
- ],
369
- default: currentB || 'main',
370
- clearPromptOnDone: true,
371
- validate: (v) => {
372
- if (!v || !v.trim()) return true;
373
- return isValidBranchName(v.trim()) || 'Tên nhánh không hợp lệ: không dùng dấu cách, .., ~ ^ : ? * [ \\ hoặc @{.';
374
- }
375
- });
376
- if (val === BACK) {
377
- step = 2;
378
- continue;
379
- }
380
- branchName = String(val || currentB || 'main').trim();
381
- if (!isValidBranchName(branchName)) {
382
- printErrorBox('TÊN NHÁNH KHÔNG HỢP LỆ', [`Tên nhánh: '${branchName}'`]);
383
- continue;
384
- }
385
- step = 4;
386
- continue;
387
- }
388
-
389
- // BƯỚC 4: 💬 Nhập commit message
390
- if (step === 4) {
391
- if (eff.message) {
392
- commitMsg = String(eff.message).trim();
393
- delete eff.message;
394
- step = 5;
395
- continue;
396
- }
397
- const val = await inputWithBack({
398
- message: 'Viết commit message',
399
- promptLabel: 'Commit',
400
- hint: [
401
- 'Mô tả ngắn thay đổi bạn sắp đẩy.',
402
- 'Ví dụ: feat: thêm đăng nhập · fix: sửa lỗi push.',
403
- ],
404
- default: 'Initial commit',
405
- clearPromptOnDone: true
406
- });
407
- if (val === BACK) {
408
- step = 3;
409
- continue;
410
- }
411
- commitMsg = String(val || '').trim() || 'Initial commit';
412
- step = 5;
413
- continue;
414
- }
415
-
416
- // BƯỚC 5: ⏰ Chọn commit time
417
- if (step === 5) {
418
- if (eff.date) {
419
- dateInput = eff.date;
420
- delete eff.date;
421
- step = 6;
422
- continue;
423
- }
424
- const dateOption = await selectWithBack({
425
- message: 'Bước 6/7 · Thời gian commit',
426
- hint: 'Chọn mốc thời gian ghi vào commit',
427
- clearPromptOnDone: true,
428
- choices: [
429
- { name: 'Ngay bây giờ', value: 'now', description: 'Sử dụng thời gian hiện tại' },
430
- { name: 'Lùi thời gian (Backdate)', value: 'relative', description: 'VD: -2d (2 ngày trước), -5h, -30m, -1w' },
431
- { name: 'Ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
432
- ]
433
- });
434
- if (dateOption === BACK) {
435
- step = 4;
436
- continue;
437
- }
438
-
439
- if (dateOption === 'now') {
440
- dateInput = 'now';
441
- } else if (dateOption === 'relative') {
442
- const val = await inputWithBack({
443
- message: 'Lùi thời gian commit (Backdate)',
444
- hint: ['-2d = 2 ngày trước · -5h = 5 giờ trước · -30m = 30 phút trước · -1w = 1 tuần trước'],
445
- default: '-1d',
446
- clearPromptOnDone: true,
447
- validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Thời gian không hợp lệ'; }
448
- });
449
- if (val === BACK) continue;
450
- dateInput = val;
451
- } else if (dateOption === 'custom') {
452
- const val = await inputWithBack({
453
- message: 'Ngày giờ cụ thể cho commit',
454
- hint: ['Định dạng: YYYY-MM-DD HH:mm:ss (VD: 2024-01-15 10:00:00)'],
455
- default: '2024-01-15 10:00:00',
456
- clearPromptOnDone: true,
457
- validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Ngày giờ không hợp lệ'; }
458
- });
459
- if (val === BACK) continue;
460
- dateInput = val;
461
- }
462
-
463
- parsedDate = parseCustomDate(dateInput || 'now');
464
- if (!parsedDate.valid) {
465
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error]);
466
- const retry = await promptContinueOrBack('Thời gian chưa đúng. Bạn muốn nhập lại hay quay lại?');
467
- if (retry === 'exit') { printCancelled(); process.exit(0); }
468
- dateInput = undefined;
469
- delete eff.date;
470
- if (retry === 'back') step = 4;
471
- continue;
472
- }
473
- step = 6;
474
- continue;
475
- }
476
-
477
- // BƯỚC 6: 🚀 Xác nhận & Đẩy lên
478
- if (step === 6) {
479
- if (!commitMsg) commitMsg = 'Initial commit';
480
- if (!dateInput) dateInput = 'now';
481
- if (!branchName) branchName = 'main';
482
- parsedDate = parseCustomDate(dateInput);
483
-
484
- printSummary({
485
- repoUrl,
486
- commitMsg,
487
- dateDisplay: parsedDate.display,
488
- branch: branchName,
489
- force: options.force,
490
- sourceDir: targetDir
491
- });
492
-
493
- const decision = await selectBox({
494
- message: 'Xác nhận đẩy dự án lên Git ngay?',
495
- hint: 'Kiểm tra tóm tắt bên trên trước khi push',
496
- choices: [
497
- { name: 'Bắt đầu đẩy code (Push)', value: 'continue', description: 'Thực thi git init/add/commit/push' },
498
- { name: 'Sửa thời gian commit', value: 'back-date', description: 'Về bước chọn thời gian' },
499
- { name: 'Sửa commit message', value: 'back-msg', description: 'Về bước nhập message' },
500
- { name: 'Sửa branch', value: 'back-branch', description: 'Về bước chọn branch' },
501
- { name: 'Sửa repository', value: 'back-repo', description: 'Về bước chọn repository' },
502
- { name: 'Sửa tài khoản GitHub', value: 'back-account', description: 'Về bước chọn tài khoản GitHub' },
503
- { name: 'Chọn lại folder dự án', value: 'back-folder', description: 'Về bước chọn thư mục' },
504
- { name: 'Về menu chính', value: 'back-menu', description: 'Quay lại màn hình điều khiển' },
505
- { name: 'x Hủy bỏ', value: 'exit', description: 'Thoát' }
506
- ]
507
- });
508
-
509
- if (decision === 'exit') { printCancelled(); process.exit(0); }
510
- if (decision === 'continue') break;
511
- if (decision === 'back-date') { dateInput = undefined; delete eff.date; parsedDate = null; step = 5; continue; }
512
- if (decision === 'back-msg') { commitMsg = undefined; delete eff.message; step = 4; continue; }
513
- if (decision === 'back-branch') { branchName = undefined; delete eff.branch; step = 3; continue; }
514
- if (decision === 'back-repo') { repoUrl = undefined; delete eff.repo; step = 2; continue; }
515
- if (decision === 'back-account') { step = 1; continue; }
516
- if (decision === 'back-folder') { delete eff.dir; step = 0; continue; }
517
- if (decision === 'back-menu') {
518
- if (onBackToMenu) return await onBackToMenu();
519
- step = options.dir ? 1 : 0;
520
- continue;
521
- }
522
- }
523
- }
524
- }
525
-
526
- // 🚀 BƯỚC 7: THỰC THI PUSH
527
- const spinner = ora({ color: 'cyan' });
528
- let secretStatus = '✓ Không phát hiện';
529
- let pushDurationSeconds = null;
530
- printSection(`🚀 ĐANG ĐẨY CODE LÊN GIT (${TOTAL_STEPS} BƯỚC)`);
531
-
532
- try {
533
- // 1. Git init
534
- if (!isGitRepo(targetDir)) {
535
- spinner.start(stepLabel(1, TOTAL_STEPS, 'Khởi tạo Git repo (git init)...'));
536
- initGitRepo(targetDir);
537
- spinner.succeed(stepLabel(1, TOTAL_STEPS, chalk.hex('#34D399')('Đã khởi tạo Git repo')));
538
- } else {
539
- console.log(' ' + stepLabel(1, TOTAL_STEPS, chalk.hex('#64748B')('Đã có Git repo sẵn — bỏ qua init')));
540
- }
541
-
542
- // 2. Remote
543
- spinner.start(stepLabel(2, TOTAL_STEPS, 'Cấu hình remote origin...'));
544
- setGitRemote(repoUrl, targetDir);
545
- spinner.succeed(stepLabel(2, TOTAL_STEPS, chalk.hex('#34D399')('Cấu hình remote origin hoàn tất')));
546
-
547
- // 3. Stage
548
- spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)...'));
549
- stageAllFiles(targetDir);
550
- spinner.succeed(stepLabel(3, TOTAL_STEPS, chalk.hex('#34D399')('Đã stage toàn bộ files')));
551
-
552
- // 3.5. Quét secret nhạy cảm
553
- const risky = findRiskyStagedFiles(targetDir);
554
- const findings = scanStagedContentForSecrets(targetDir);
555
- if (risky.length > 0 || findings.length > 0) secretStatus = `! Có ${risky.length + findings.length} cảnh báo`;
556
- if (risky.length > 0 || findings.length > 0) {
557
- spinner.stop();
558
- printPrePushSecretWarning({ riskyFiles: risky, findings });
559
- const allFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
560
-
561
- let wantFix = options.fix === true;
562
- if (!wantFix && isInteractive) {
563
- const fixChoice = await selectWithBack({
564
- message: 'Tự động gỡ file secret an toàn? (giữ nguyên file ở máy, gỡ khỏi commit)',
565
- choices: [
566
- { name: 'Có, tự động gỡ file nhạy cảm', value: 'yes', description: 'Gỡ file secret khỏi stage & thêm .gitignore' },
567
- { name: 'Không, giữ nguyên', value: 'no', description: 'Tiếp tục mà không gỡ' }
568
- ]
569
- });
570
- if (fixChoice === BACK) {
571
- printCancelled();
572
- process.exit(0);
573
- }
574
- wantFix = fixChoice === 'yes';
575
- }
576
-
577
- if (wantFix) {
578
- spinner.start('Đang tự fix: gỡ file secret khỏi stage...');
579
- const res = autoFixStagedSecrets(allFiles, targetDir);
580
- spinner.succeed(chalk.hex('#34D399')(`[OK] Đã gỡ ${res.fixed.length} file khỏi commit`));
581
- printFixResult(res);
582
-
583
- const remainingRisky = findRiskyStagedFiles(targetDir);
584
- const remainingFindings = scanStagedContentForSecrets(targetDir);
585
- secretStatus = remainingRisky.length || remainingFindings.length
586
- ? `! Còn ${remainingRisky.length + remainingFindings.length} cảnh báo`
587
- : '✓ Đã gỡ cảnh báo';
588
-
589
- spawnSync('git', ['add', '.gitignore'], { cwd: targetDir, stdio: 'ignore' });
590
-
591
- if (!hasStagedChanges(targetDir)) {
592
- printErrorBox('KHÔNG CÒN FILE ĐỂ COMMIT', [
593
- 'Sau khi gỡ secret thì không còn file nào để commit.',
594
- 'File của bạn vẫn còn trên máy, không bị mất.'
595
- ]);
596
- if (fromMenu) return 'back';
597
- process.exit(0);
598
- }
599
- console.log(chalk.hex('#34D399')(' » Tiếp tục commit/push phần code an toàn.\n'));
600
- }
601
- }
602
-
603
- // 4. Commit
604
- spinner.start(stepLabel(4, TOTAL_STEPS, `Tạo commit [${parsedDate.display}]...`));
605
- if (!hasChangesToCommit(targetDir)) {
606
- spinner.info(stepLabel(4, TOTAL_STEPS, chalk.hex('#94A3B8')('Không có thay đổi mới — dùng commit hiện có')));
607
- } else {
608
- commitWithCustomDate(commitMsg, parsedDate.formatted, targetDir);
609
- spinner.succeed(stepLabel(4, TOTAL_STEPS, chalk.hex('#34D399')(`Commit hoàn tất • ${chalk.hex('#C084FC').bold(parsedDate.display)}`)));
610
- }
611
-
612
- // 5. Branch
613
- spinner.start(stepLabel(5, TOTAL_STEPS, `Đổi branch -> '${branchName}'...`));
614
- setBranchName(branchName, targetDir);
615
- spinner.succeed(stepLabel(5, TOTAL_STEPS, chalk.hex('#34D399')(`Branch: ${chalk.hex('#5EEAD4').bold(branchName)}`)));
616
-
617
- // 6. Push
618
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lên origin/${branchName}...`));
619
- const pushStartedAt = Date.now();
620
- pushToRemote(branchName, options.force, targetDir);
621
- pushDurationSeconds = (Date.now() - pushStartedAt) / 1000;
622
- spinner.succeed(stepLabel(6, TOTAL_STEPS, chalk.bold.hex('#34D399')('Push lên remote thành công!')));
623
-
624
- printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
625
- printFooter();
626
- } catch (error) {
627
- spinner.stop();
628
- if (error.code === 'SECRET_BLOCKED') {
629
- printSecretBlockedBox(error.message, error.details || {});
630
-
631
- const unpushed = getUnpushedCommits(branchName, targetDir);
632
- if (unpushed.length > 0) {
633
- 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ũ.`));
634
- let doSquash = options.fix === true;
635
- if (!doSquash && isInteractive) {
636
- const sqChoice = await selectWithBack({
637
- message: 'Tự động làm sạch lịch sử (squash các commit thành 1 commit sạch) để push lại?',
638
- choices: [
639
- { name: 'Có, squash và push lại ngay', value: 'yes', description: 'Làm sạch commit' },
640
- { name: 'Không, để tự xử lý', value: 'no', description: 'Dừng' }
641
- ]
642
- });
643
- doSquash = sqChoice === 'yes';
644
- }
645
- if (doSquash) {
646
- spinner.start('Đang squash và làm sạch commit chưa push...');
647
- const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted, targetDir);
648
- if (sqRes.ok) {
649
- spinner.succeed(chalk.green('[OK] Đã làm sạch commit! Đang thử push lại...'));
650
- try {
651
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
652
- const retryStartedAt = Date.now();
653
- pushToRemote(branchName, options.force, targetDir);
654
- pushDurationSeconds = (Date.now() - retryStartedAt) / 1000;
655
- spinner.succeed(chalk.bold.green('[OK] Push thành công'));
656
- printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
657
- printFooter();
658
- return;
659
- } catch (retryErr) {
660
- spinner.fail(chalk.red(`Push lại thất bại: ${retryErr.message}`));
661
- }
662
- } else {
663
- spinner.fail(chalk.red(`Không thể squash: ${sqRes.error}`));
664
- }
665
- }
666
- }
667
- } else if (error.code === 'AUTH_FAILED') {
668
- printAuthFailedBox(error.message);
669
- } else if (error.code === 'NON_FAST_FORWARD') {
670
- printNonFastForwardBox(error.message, branchName);
671
- if (isInteractive) {
672
- const ffChoice = await selectBox({
673
- message: 'Remote đã có commit mới hơn. Bạn muốn xử lý thế nào?',
674
- hint: `git pull --rebase origin ${branchName} rồi push lại`,
675
- choices: [
676
- { 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` },
677
- { name: 'Chỉ Pull về (chưa push vội)', value: 'pull-only', description: 'Chỉ đồng bộ code về máy' },
678
- { name: 'Quay lại menu chính', value: 'back', description: 'Về màn hình điều khiển' },
679
- { name: 'x Thoát', value: 'exit', description: 'Dừng lại' }
680
- ]
681
- });
682
- if (ffChoice === 'pull-push' || ffChoice === 'pull-only') {
683
- try {
684
- spinner.start(`Đang pull --rebase origin/${branchName}...`);
685
- const pullOut = pullFromRemote('origin', branchName, targetDir, { rebase: true });
686
- spinner.succeed(chalk.hex('#34D399')(`Đã pull --rebase origin/${branchName} xong`));
687
- if (pullOut) console.log(chalk.hex('#64748B')(` ${String(pullOut).split('\n')[0].slice(0, 120)}`));
688
- if (ffChoice === 'pull-push') {
689
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
690
- const retryStartedAt = Date.now();
691
- pushToRemote(branchName, options.force, targetDir);
692
- pushDurationSeconds = (Date.now() - retryStartedAt) / 1000;
693
- spinner.succeed(chalk.bold.hex('#34D399')('Push lại thành công sau khi pull!'));
694
- printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
695
- printFooter();
696
- return;
697
- }
698
- console.log(chalk.hex('#34D399')('\n [OK] Đã pull code mới nhất về.\n'));
699
- if (fromMenu) return 'back';
700
- return;
701
- } catch (pullErr) {
702
- spinner.fail(chalk.red(`Pull --rebase thất bại: ${String(pullErr.message).split('\n')[0]}`));
703
- printErrorBox('PULL --REBASE THẤT BẠI', [String(pullErr.message).split('\n')[0]]);
704
- if (fromMenu) return 'back';
705
- return;
706
- }
707
- } else if (ffChoice === 'back' && onBackToMenu) {
708
- return await onBackToMenu();
709
- }
710
- }
711
- } else if (error.code === 'REMOTE_OR_NETWORK') {
712
- 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.');
713
- } else {
714
- printErrorBox('ĐẨY LÊN GIT THẤT BẠI', [error.message]);
715
- }
716
- // Khi chạy từ menu tương tác: quay về menu thay vì kill process
717
- if (fromMenu) return 'back';
718
- process.exit(1);
719
- }
720
- }
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 { isValidGitUrl, parseCustomDate } from '../utils.js';
7
+ import {
8
+ isGitRepo,
9
+ initGitRepo,
10
+ getGitTopLevel,
11
+ listSubdirectories,
12
+ resolveTargetDir,
13
+ setGitRemote,
14
+ stageAllFiles,
15
+ hasChangesToCommit,
16
+ commitWithCustomDate,
17
+ setBranchName,
18
+ isValidBranchName,
19
+ pushToRemote,
20
+ getCurrentBranch,
21
+ findRiskyStagedFiles,
22
+ pullFromRemote
23
+ } from '../git.js';
24
+ import { scanStagedContentForSecrets } from '../secrets.js';
25
+ import {
26
+ autoFixStagedSecrets,
27
+ hasStagedChanges,
28
+ getUnpushedCommits,
29
+ squashUnpushedCommits
30
+ } from '../fix.js';
31
+ import {
32
+ getCurrentGitHubUser,
33
+ listUserRepos
34
+ } from '../github.js';
35
+ import { promptCreateRepo, promptSelectGitHubAccount } from './github.js';
36
+ import { selectWithBack, inputWithBack, promptContinueOrBack, selectBox, BACK } from '../prompt-helpers.js';
37
+ import {
38
+ printSummary,
39
+ printSection,
40
+ printPushProgress,
41
+ stepLabel,
42
+ printNestedRepoWarning,
43
+ printPrePushSecretWarning,
44
+ printSecretBlockedBox,
45
+ printSuccessBox,
46
+ printCancelled,
47
+ printErrorBox,
48
+ printAuthFailedBox,
49
+ printNonFastForwardBox,
50
+ printFixResult,
51
+ printFooter
52
+ } from '../ui.js';
53
+
54
+ const TOTAL_STEPS = 6;
55
+ const WIZARD_STEPS = 7;
56
+
57
+ /**
58
+ * Let the user choose the source folder to push.
59
+ */
60
+ export async function promptForTargetDir(startDir, clearPromptOnDone = false) {
61
+ const subs = listSubdirectories(startDir);
62
+ if (subs.length === 0) return startDir;
63
+
64
+ const picked = await selectWithBack({
65
+ message: 'Which project folder should be pushed to Git?',
66
+ clearPromptOnDone,
67
+ pageSize: Math.min(12, subs.length + 3),
68
+ choices: [
69
+ { name: 'Current folder (.)', value: startDir, description: 'Push the entire current folder' },
70
+ ...subs.map((s) => ({
71
+ name: `${s.name}`,
72
+ value: s.fullPath,
73
+ description: s.fullPath
74
+ })),
75
+ { name: 'Enter another path...', value: '__custom__', description: 'Type an absolute or relative path' }
76
+ ]
77
+ });
78
+
79
+ if (picked === BACK) return BACK;
80
+ if (picked !== '__custom__') return picked;
81
+
82
+ const customPath = await inputWithBack({
83
+ message: 'Project folder path',
84
+ hint: ['Enter an absolute or relative path (e.g. ./my-app, E:/projects/demo)'],
85
+ clearPromptOnDone,
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
+ * Handle a folder nested inside a parent repository.
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: 'How would you like to push this folder?',
110
+ choices: [
111
+ { name: 'Create a SEPARATE repository for this folder (recommended)', value: 'separate', description: 'Run git init here and push only this folder' },
112
+ { name: 'Use the parent repository', value: 'shared', description: 'Keep the parent repository and push from it' },
113
+ { name: 'Cancel and review', value: 'abort', description: 'Exit without making changes' }
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] Created a separate Git repository for this folder.\n'));
130
+ } else {
131
+ console.log(chalk.hex('#64748B')(' » Using the parent repository (only files in this folder will be staged).\n'));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Prompt the user to select or create a repository URL.
137
+ */
138
+ async function promptForRepoSelection(targetDir, gitHubUser = null, clearPromptOnDone = false) {
139
+ const choices = [];
140
+
141
+ if (gitHubUser) {
142
+ choices.push(
143
+ {
144
+ name: `Choose from @${gitHubUser.login}'s repositories`,
145
+ value: 'pick_existing',
146
+ description: 'Quickly choose one of your recently updated GitHub repositories'
147
+ },
148
+ {
149
+ name: 'Create a NEW GitHub repository',
150
+ value: 'create_repo',
151
+ description: `Create a repository under @${gitHubUser.login}`
152
+ }
153
+ );
154
+ } else {
155
+ choices.push(
156
+ {
157
+ name: 'Create a NEW GitHub repository',
158
+ value: 'create_repo',
159
+ description: 'Sign in and create a repository'
160
+ }
161
+ );
162
+ }
163
+
164
+ choices.push({
165
+ name: 'Enter a Git repository URL manually',
166
+ value: 'manual_url',
167
+ description: 'Enter an HTTPS or SSH URL (GitHub, GitLab, Bitbucket...)'
168
+ });
169
+
170
+ const method = await selectWithBack({
171
+ message: 'How would you like to choose a repository?',
172
+ clearPromptOnDone,
173
+ choices
174
+ });
175
+
176
+ if (method === BACK) return BACK;
177
+
178
+ if (method === 'create_repo') {
179
+ const res = await promptCreateRepo({ targetDir });
180
+ if (!res.ok || !res.repo) return BACK;
181
+ return res.repo.cloneUrl;
182
+ }
183
+
184
+ if (method === 'pick_existing') {
185
+ const spinner = ora({ text: 'Loading repositories from GitHub…', color: 'cyan' }).start();
186
+ const res = await listUserRepos({ limit: 25 });
187
+ spinner.stop();
188
+
189
+ if (!res.ok || !res.repos || res.repos.length === 0) {
190
+ console.log(chalk.hex('#F59E0B')(' [!] No repositories found for this account.'));
191
+ } else {
192
+ const repoChoices = res.repos.map((r) => ({
193
+ name: `${r.private ? chalk.hex('#F59E0B')('[Private]') : chalk.hex('#10B981')('[Public ]')} ${chalk.hex('#F8FAFC').bold(r.name)}`,
194
+ value: r.clone_url,
195
+ description: r.clone_url
196
+ }));
197
+
198
+ const picked = await selectWithBack({
199
+ message: 'Choose the repository to push to:',
200
+ clearPromptOnDone,
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
+ // Enter a URL manually.
210
+ const val = await inputWithBack({
211
+ message: 'Git repository URL',
212
+ hint: ['HTTPS or SSH (e.g. https://github.com/user/repo.git)'],
213
+ clearPromptOnDone,
214
+ validate: (value) => {
215
+ if (!value || !value.trim()) return 'Enter a repository URL.';
216
+ if (!isValidGitUrl(value)) return 'Invalid URL (e.g. https://github.com/user/repo.git).';
217
+ return true;
218
+ }
219
+ });
220
+
221
+ return val;
222
+ }
223
+
224
+ /**
225
+ * Run the standard seven-step push flow.
226
+ */
227
+ export async function runPushWizard(options = {}, onBackToMenu = null) {
228
+ const isInteractive = !options.yes;
229
+ const fromMenu = typeof onBackToMenu === 'function';
230
+ const startDir = process.cwd();
231
+ // Copy flags into a mutable object so back navigation can clear consumed flags
232
+ // and avoid looping when --repo/--branch/--message/--date remain truthy.
233
+ const eff = { ...options };
234
+
235
+ let targetDir = startDir;
236
+ let selectedGitHubUser = null;
237
+ let repoUrl = eff.repo;
238
+ let branchName = eff.branch ? String(eff.branch).trim() : undefined;
239
+ let commitMsg = eff.message ? String(eff.message).trim() : undefined;
240
+ let dateInput = eff.date;
241
+ let parsedDate = null;
242
+
243
+ if (eff.dir) {
244
+ const r = resolveTargetDir(eff.dir, startDir);
245
+ if (!r.ok) {
246
+ printErrorBox('INVALID SOURCE DIRECTORY', [r.error], 'Example: --dir ./my-app or --dir "E:/projects/my-app"');
247
+ process.exit(1);
248
+ }
249
+ targetDir = r.fullPath;
250
+ }
251
+
252
+ if (!isInteractive) {
253
+ if (!commitMsg) commitMsg = 'Initial commit';
254
+ if (!dateInput) dateInput = 'now';
255
+ if (!branchName) branchName = 'main';
256
+ if (!repoUrl || !isValidGitUrl(repoUrl)) {
257
+ printErrorBox('INVALID REPOSITORY URL', [`Received URL: '${repoUrl || '(empty)'}'`], 'Example: https://github.com/username/repository.git');
258
+ process.exit(1);
259
+ }
260
+ parsedDate = parseCustomDate(dateInput);
261
+ if (!parsedDate.valid) {
262
+ printErrorBox('INVALID DATE OR TIME', [parsedDate.error], 'Examples: now, -2d, -5h, 2024-01-15 14:30:00');
263
+ process.exit(1);
264
+ }
265
+ printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
266
+ } else {
267
+ // Standard seven-step interactive flow:
268
+ // 0: 📁 Choose folder
269
+ // 1: 👤 Choose GitHub account
270
+ // 2: 📦 Choose repository
271
+ // 3: 🌿 Choose branch
272
+ // 4: 💬 Enter commit message
273
+ // 5: ⏰ Choose commit time
274
+ // 6: 🚀 Review and push
275
+ let step = eff.dir ? 1 : 0;
276
+
277
+ // Skip repository selection once when a URL was supplied as a flag.
278
+ if (eff.repo) {
279
+ repoUrl = eff.repo;
280
+ delete eff.repo;
281
+ step = 3;
282
+ }
283
+
284
+ while (true) {
285
+ const progressTitles = ['Folder', 'Account', 'Repository', 'Branch', 'Message', 'Time', 'Confirm'];
286
+ const completed = [];
287
+ if (step > 0) completed.push({ label: 'Folder', value: targetDir });
288
+ if (step > 1) completed.push({ label: 'Account', value: selectedGitHubUser?.login || 'Manual URL' });
289
+ if (step > 2) {
290
+ const shortRepo = String(repoUrl || '').replace(/\.git$/i, '').match(/(?:github\.com[:/])([^/]+\/[^/]+)$/i)?.[1] || repoUrl;
291
+ completed.push({ label: 'Repository', value: shortRepo || 'Selected' });
292
+ }
293
+ if (step > 3) completed.push({ label: 'Branch', value: branchName || 'main' });
294
+ if (step > 4) completed.push({ label: 'Message', value: commitMsg || 'Initial commit' });
295
+ if (step > 5) completed.push({ label: 'Time', value: parsedDate?.display || dateInput || 'now' });
296
+ printPushProgress({ current: step + 1, total: WIZARD_STEPS, title: progressTitles[step], completed });
297
+
298
+ // STEP 0: 📁 Choose project folder.
299
+ if (step === 0) {
300
+ const picked = await promptForTargetDir(startDir, true);
301
+ if (picked === BACK) {
302
+ if (onBackToMenu) return await onBackToMenu();
303
+ printCancelled();
304
+ process.exit(0);
305
+ }
306
+ targetDir = picked;
307
+ delete eff.dir;
308
+ const nested = await handleNestedRepo(targetDir, isInteractive, eff);
309
+ if (nested === BACK) continue;
310
+ step = 1;
311
+ continue;
312
+ }
313
+
314
+ // STEP 1: 👤 Choose GitHub account.
315
+ if (step === 1) {
316
+ const accRes = await promptSelectGitHubAccount({ clearPromptOnDone: true });
317
+ if (accRes.back) {
318
+ // Allow returning to folder selection even when --dir was provided.
319
+ delete eff.dir;
320
+ step = 0;
321
+ continue;
322
+ }
323
+ selectedGitHubUser = accRes.useGitHub ? accRes.user : null;
324
+ step = 2;
325
+ continue;
326
+ }
327
+
328
+ // STEP 2: 📦 Choose repository.
329
+ if (step === 2) {
330
+ if (eff.repo) {
331
+ repoUrl = eff.repo;
332
+ delete eff.repo;
333
+ step = 3;
334
+ continue;
335
+ }
336
+ const pickedUrl = await promptForRepoSelection(targetDir, selectedGitHubUser, true);
337
+ if (pickedUrl === BACK) {
338
+ step = 1;
339
+ continue;
340
+ }
341
+ repoUrl = pickedUrl;
342
+ if (!repoUrl || !isValidGitUrl(repoUrl)) {
343
+ printErrorBox('INVALID REPOSITORY URL', [`URL: '${repoUrl || '(empty)'}'`]);
344
+ const retry = await promptContinueOrBack('Invalid URL. Would you like to try again or go back?');
345
+ if (retry === 'exit') { printCancelled(); process.exit(0); }
346
+ if (retry === 'back') step = 1;
347
+ continue;
348
+ }
349
+ step = 3;
350
+ continue;
351
+ }
352
+
353
+ // STEP 3: 🌿 Choose branch.
354
+ if (step === 3) {
355
+ if (eff.branch) {
356
+ branchName = String(eff.branch).trim();
357
+ delete eff.branch;
358
+ step = 4;
359
+ continue;
360
+ }
361
+ const currentB = getCurrentBranch(targetDir);
362
+ const val = await inputWithBack({
363
+ message: 'Choose a Git branch name',
364
+ promptLabel: 'Branch name',
365
+ hint: [
366
+ 'The local branch will be renamed before it is pushed to origin.',
367
+ 'Examples: main, dev, feature/login.',
368
+ ],
369
+ default: currentB || 'main',
370
+ clearPromptOnDone: true,
371
+ validate: (v) => {
372
+ if (!v || !v.trim()) return true;
373
+ return isValidBranchName(v.trim()) || 'Invalid branch name. Avoid spaces, .., ~ ^ : ? * [ \\ and @{.';
374
+ }
375
+ });
376
+ if (val === BACK) {
377
+ step = 2;
378
+ continue;
379
+ }
380
+ branchName = String(val || currentB || 'main').trim();
381
+ if (!isValidBranchName(branchName)) {
382
+ printErrorBox('INVALID BRANCH NAME', [`Branch: '${branchName}'`]);
383
+ continue;
384
+ }
385
+ step = 4;
386
+ continue;
387
+ }
388
+
389
+ // STEP 4: 💬 Enter commit message.
390
+ if (step === 4) {
391
+ if (eff.message) {
392
+ commitMsg = String(eff.message).trim();
393
+ delete eff.message;
394
+ step = 5;
395
+ continue;
396
+ }
397
+ const val = await inputWithBack({
398
+ message: 'Write a commit message',
399
+ promptLabel: 'Commit message',
400
+ hint: [
401
+ 'Briefly describe the changes you are about to push.',
402
+ 'Examples: feat: add sign-in · fix: resolve push error.',
403
+ ],
404
+ default: 'Initial commit',
405
+ clearPromptOnDone: true
406
+ });
407
+ if (val === BACK) {
408
+ step = 3;
409
+ continue;
410
+ }
411
+ commitMsg = String(val || '').trim() || 'Initial commit';
412
+ step = 5;
413
+ continue;
414
+ }
415
+
416
+ // STEP 5: ⏰ Choose commit time.
417
+ if (step === 5) {
418
+ if (eff.date) {
419
+ dateInput = eff.date;
420
+ delete eff.date;
421
+ step = 6;
422
+ continue;
423
+ }
424
+ const dateOption = await selectWithBack({
425
+ message: 'Step 6/7 · Commit time',
426
+ hint: 'Choose the timestamp to record in the commit',
427
+ clearPromptOnDone: true,
428
+ choices: [
429
+ { name: 'Now', value: 'now', description: 'Use the current time' },
430
+ { name: 'Relative time (backdate)', value: 'relative', description: 'e.g. -2d (2 days ago), -5h, -30m, -1w' },
431
+ { name: 'Specific date and time', value: 'custom', description: 'e.g. 2024-01-15 14:30:00' }
432
+ ]
433
+ });
434
+ if (dateOption === BACK) {
435
+ step = 4;
436
+ continue;
437
+ }
438
+
439
+ if (dateOption === 'now') {
440
+ dateInput = 'now';
441
+ } else if (dateOption === 'relative') {
442
+ const val = await inputWithBack({
443
+ message: 'Backdate the commit',
444
+ hint: ['-2d = 2 days ago · -5h = 5 hours ago · -30m = 30 minutes ago · -1w = 1 week ago'],
445
+ default: '-1d',
446
+ clearPromptOnDone: true,
447
+ validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Invalid time'; }
448
+ });
449
+ if (val === BACK) continue;
450
+ dateInput = val;
451
+ } else if (dateOption === 'custom') {
452
+ const val = await inputWithBack({
453
+ message: 'Set a specific commit date and time',
454
+ hint: ['Format: YYYY-MM-DD HH:mm:ss (e.g. 2024-01-15 10:00:00)'],
455
+ default: '2024-01-15 10:00:00',
456
+ clearPromptOnDone: true,
457
+ validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Invalid date and time'; }
458
+ });
459
+ if (val === BACK) continue;
460
+ dateInput = val;
461
+ }
462
+
463
+ parsedDate = parseCustomDate(dateInput || 'now');
464
+ if (!parsedDate.valid) {
465
+ printErrorBox('INVALID DATE OR TIME', [parsedDate.error]);
466
+ const retry = await promptContinueOrBack('Invalid time. Would you like to try again or go back?');
467
+ if (retry === 'exit') { printCancelled(); process.exit(0); }
468
+ dateInput = undefined;
469
+ delete eff.date;
470
+ if (retry === 'back') step = 4;
471
+ continue;
472
+ }
473
+ step = 6;
474
+ continue;
475
+ }
476
+
477
+ // STEP 6: 🚀 Review and push.
478
+ if (step === 6) {
479
+ if (!commitMsg) commitMsg = 'Initial commit';
480
+ if (!dateInput) dateInput = 'now';
481
+ if (!branchName) branchName = 'main';
482
+ parsedDate = parseCustomDate(dateInput);
483
+
484
+ printSummary({
485
+ repoUrl,
486
+ commitMsg,
487
+ dateDisplay: parsedDate.display,
488
+ branch: branchName,
489
+ force: options.force,
490
+ sourceDir: targetDir
491
+ });
492
+
493
+ const decision = await selectBox({
494
+ message: 'Ready to push this project to Git?',
495
+ hint: 'Review the summary above before pushing',
496
+ choices: [
497
+ { name: 'Push project', value: 'continue', description: 'Run git init/add/commit/push' },
498
+ { name: 'Change commit time', value: 'back-date', description: 'Return to the time step' },
499
+ { name: 'Edit commit message', value: 'back-msg', description: 'Return to the message step' },
500
+ { name: 'Change branch', value: 'back-branch', description: 'Return to the branch step' },
501
+ { name: 'Change repository', value: 'back-repo', description: 'Return to the repository step' },
502
+ { name: 'Change GitHub account', value: 'back-account', description: 'Return to the account step' },
503
+ { name: 'Choose another project folder', value: 'back-folder', description: 'Return to the folder step' },
504
+ { name: 'Back to dashboard', value: 'back-menu', description: 'Return to the main menu' },
505
+ { name: 'Cancel', value: 'exit', description: 'Exit without continuing' }
506
+ ]
507
+ });
508
+
509
+ if (decision === 'exit') { printCancelled(); process.exit(0); }
510
+ if (decision === 'continue') break;
511
+ if (decision === 'back-date') { dateInput = undefined; delete eff.date; parsedDate = null; step = 5; continue; }
512
+ if (decision === 'back-msg') { commitMsg = undefined; delete eff.message; step = 4; continue; }
513
+ if (decision === 'back-branch') { branchName = undefined; delete eff.branch; step = 3; continue; }
514
+ if (decision === 'back-repo') { repoUrl = undefined; delete eff.repo; step = 2; continue; }
515
+ if (decision === 'back-account') { step = 1; continue; }
516
+ if (decision === 'back-folder') { delete eff.dir; step = 0; continue; }
517
+ if (decision === 'back-menu') {
518
+ if (onBackToMenu) return await onBackToMenu();
519
+ step = options.dir ? 1 : 0;
520
+ continue;
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ // 🚀 STEP 7: PERFORM THE PUSH.
527
+ const spinner = ora({ color: 'cyan' });
528
+ let secretStatus = '✓ None found';
529
+ let pushDurationSeconds = null;
530
+ printSection(`🚀 PUSHING TO GIT (${TOTAL_STEPS} STEPS)`);
531
+
532
+ try {
533
+ // 1. Git init
534
+ if (!isGitRepo(targetDir)) {
535
+ spinner.start(stepLabel(1, TOTAL_STEPS, 'Initialize Git repository (git init)...'));
536
+ initGitRepo(targetDir);
537
+ spinner.succeed(stepLabel(1, TOTAL_STEPS, chalk.hex('#34D399')('Git repository initialized')));
538
+ } else {
539
+ console.log(' ' + stepLabel(1, TOTAL_STEPS, chalk.hex('#64748B')('Git repository already exists — skipping init')));
540
+ }
541
+
542
+ // 2. Remote
543
+ spinner.start(stepLabel(2, TOTAL_STEPS, 'Configure origin remote...'));
544
+ setGitRemote(repoUrl, targetDir);
545
+ spinner.succeed(stepLabel(2, TOTAL_STEPS, chalk.hex('#34D399')('Origin remote configured')));
546
+
547
+ // 3. Stage
548
+ spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)...'));
549
+ stageAllFiles(targetDir);
550
+ spinner.succeed(stepLabel(3, TOTAL_STEPS, chalk.hex('#34D399')('All files staged')));
551
+
552
+ // 3.5. Scan for secrets.
553
+ const risky = findRiskyStagedFiles(targetDir);
554
+ const findings = scanStagedContentForSecrets(targetDir);
555
+ if (risky.length > 0 || findings.length > 0) secretStatus = `! ${risky.length + findings.length} warning(s)`;
556
+ if (risky.length > 0 || findings.length > 0) {
557
+ spinner.stop();
558
+ printPrePushSecretWarning({ riskyFiles: risky, findings });
559
+ const allFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
560
+
561
+ let wantFix = options.fix === true;
562
+ if (!wantFix && isInteractive) {
563
+ const fixChoice = await selectWithBack({
564
+ message: 'Safely remove the secret files? (keeps local files, removes them from the commit)',
565
+ choices: [
566
+ { name: 'Yes, remove sensitive files', value: 'yes', description: 'Unstage secret files and add them to .gitignore' },
567
+ { name: 'No, keep them', value: 'no', description: 'Continue without removing them' }
568
+ ]
569
+ });
570
+ if (fixChoice === BACK) {
571
+ printCancelled();
572
+ process.exit(0);
573
+ }
574
+ wantFix = fixChoice === 'yes';
575
+ }
576
+
577
+ if (wantFix) {
578
+ spinner.start('Removing secret files from the staged changes...');
579
+ const res = autoFixStagedSecrets(allFiles, targetDir);
580
+ spinner.succeed(chalk.hex('#34D399')(`[OK] Removed ${res.fixed.length} file(s) from the commit`));
581
+ printFixResult(res);
582
+
583
+ const remainingRisky = findRiskyStagedFiles(targetDir);
584
+ const remainingFindings = scanStagedContentForSecrets(targetDir);
585
+ secretStatus = remainingRisky.length || remainingFindings.length
586
+ ? `! ${remainingRisky.length + remainingFindings.length} warning(s) remain`
587
+ : '✓ Warnings removed';
588
+
589
+ spawnSync('git', ['add', '.gitignore'], { cwd: targetDir, stdio: 'ignore' });
590
+
591
+ if (!hasStagedChanges(targetDir)) {
592
+ printErrorBox('NOTHING LEFT TO COMMIT', [
593
+ 'No files remain to commit after removing the secrets.',
594
+ 'Your local files are still intact.'
595
+ ]);
596
+ if (fromMenu) return 'back';
597
+ process.exit(0);
598
+ }
599
+ console.log(chalk.hex('#34D399')(' » Continuing with the safe changes.\n'));
600
+ }
601
+ }
602
+
603
+ // 4. Commit
604
+ spinner.start(stepLabel(4, TOTAL_STEPS, `Create commit [${parsedDate.display}]...`));
605
+ if (!hasChangesToCommit(targetDir)) {
606
+ spinner.info(stepLabel(4, TOTAL_STEPS, chalk.hex('#94A3B8')('No new changes — using the existing commit')));
607
+ } else {
608
+ commitWithCustomDate(commitMsg, parsedDate.formatted, targetDir);
609
+ spinner.succeed(stepLabel(4, TOTAL_STEPS, chalk.hex('#34D399')(`Commit created • ${chalk.hex('#C084FC').bold(parsedDate.display)}`)));
610
+ }
611
+
612
+ // 5. Branch
613
+ spinner.start(stepLabel(5, TOTAL_STEPS, `Switch branch -> '${branchName}'...`));
614
+ setBranchName(branchName, targetDir);
615
+ spinner.succeed(stepLabel(5, TOTAL_STEPS, chalk.hex('#34D399')(`Branch: ${chalk.hex('#5EEAD4').bold(branchName)}`)));
616
+
617
+ // 6. Push
618
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Push to origin/${branchName}...`));
619
+ const pushStartedAt = Date.now();
620
+ pushToRemote(branchName, options.force, targetDir);
621
+ pushDurationSeconds = (Date.now() - pushStartedAt) / 1000;
622
+ spinner.succeed(stepLabel(6, TOTAL_STEPS, chalk.bold.hex('#34D399')('Successfully pushed to remote')));
623
+
624
+ printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
625
+ printFooter();
626
+ } catch (error) {
627
+ spinner.stop();
628
+ if (error.code === 'SECRET_BLOCKED') {
629
+ printSecretBlockedBox(error.message, error.details || {});
630
+
631
+ const unpushed = getUnpushedCommits(branchName, targetDir);
632
+ if (unpushed.length > 0) {
633
+ console.log(chalk.cyanBright(`\n » Found ${unpushed.length} unpushed commit(s) that may contain traces of an old secret.`));
634
+ let doSquash = options.fix === true;
635
+ if (!doSquash && isInteractive) {
636
+ const sqChoice = await selectWithBack({
637
+ message: 'Clean the history (squash commits into one clean commit) and try pushing again?',
638
+ choices: [
639
+ { name: 'Yes, squash and retry the push', value: 'yes', description: 'Clean the commit history' },
640
+ { name: 'No, I will handle it', value: 'no', description: 'Stop here' }
641
+ ]
642
+ });
643
+ doSquash = sqChoice === 'yes';
644
+ }
645
+ if (doSquash) {
646
+ spinner.start('Squashing and cleaning unpushed commits...');
647
+ const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted, targetDir);
648
+ if (sqRes.ok) {
649
+ spinner.succeed(chalk.green('[OK] Commit history cleaned. Retrying push...'));
650
+ try {
651
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Retry push to origin/${branchName}...`));
652
+ const retryStartedAt = Date.now();
653
+ pushToRemote(branchName, options.force, targetDir);
654
+ pushDurationSeconds = (Date.now() - retryStartedAt) / 1000;
655
+ spinner.succeed(chalk.bold.green('[OK] Push succeeded'));
656
+ printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
657
+ printFooter();
658
+ return;
659
+ } catch (retryErr) {
660
+ spinner.fail(chalk.red(`Retry push failed: ${retryErr.message}`));
661
+ }
662
+ } else {
663
+ spinner.fail(chalk.red(`Unable to squash commits: ${sqRes.error}`));
664
+ }
665
+ }
666
+ }
667
+ } else if (error.code === 'AUTH_FAILED') {
668
+ printAuthFailedBox(error.message);
669
+ } else if (error.code === 'NON_FAST_FORWARD') {
670
+ printNonFastForwardBox(error.message, branchName);
671
+ if (isInteractive) {
672
+ const ffChoice = await selectBox({
673
+ message: 'The remote has newer commits. What would you like to do?',
674
+ hint: `git pull --rebase origin ${branchName}, then push again`,
675
+ choices: [
676
+ { name: 'Pull with rebase, then push — Recommended', value: 'pull-push', description: `git pull --rebase origin ${branchName}, then push again` },
677
+ { name: 'Pull only', value: 'pull-only', description: 'Sync the latest changes without pushing' },
678
+ { name: 'Back to dashboard', value: 'back', description: 'Return to the main menu' },
679
+ { name: 'Exit', value: 'exit', description: 'Stop here' }
680
+ ]
681
+ });
682
+ if (ffChoice === 'pull-push' || ffChoice === 'pull-only') {
683
+ try {
684
+ spinner.start(`Pulling --rebase from origin/${branchName}...`);
685
+ const pullOut = pullFromRemote('origin', branchName, targetDir, { rebase: true });
686
+ spinner.succeed(chalk.hex('#34D399')(`Pulled --rebase from origin/${branchName}`));
687
+ if (pullOut) console.log(chalk.hex('#64748B')(` ${String(pullOut).split('\n')[0].slice(0, 120)}`));
688
+ if (ffChoice === 'pull-push') {
689
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Retry push to origin/${branchName}...`));
690
+ const retryStartedAt = Date.now();
691
+ pushToRemote(branchName, options.force, targetDir);
692
+ pushDurationSeconds = (Date.now() - retryStartedAt) / 1000;
693
+ spinner.succeed(chalk.bold.hex('#34D399')('Push succeeded after pulling'));
694
+ printSuccessBox({ branch: branchName, repoUrl, durationSeconds: pushDurationSeconds, secretStatus });
695
+ printFooter();
696
+ return;
697
+ }
698
+ console.log(chalk.hex('#34D399')('\n [OK] Pulled the latest changes.\n'));
699
+ if (fromMenu) return 'back';
700
+ return;
701
+ } catch (pullErr) {
702
+ spinner.fail(chalk.red(`Pull --rebase failed: ${String(pullErr.message).split('\n')[0]}`));
703
+ printErrorBox('PULL --REBASE FAILED', [String(pullErr.message).split('\n')[0]]);
704
+ if (fromMenu) return 'back';
705
+ return;
706
+ }
707
+ } else if (ffChoice === 'back' && onBackToMenu) {
708
+ return await onBackToMenu();
709
+ }
710
+ }
711
+ } else if (error.code === 'REMOTE_OR_NETWORK') {
712
+ printErrorBox('REMOTE CONNECTION ERROR', [String(error.message).split('\n')[0]], 'Check the repository URL, network connection, and access permissions.');
713
+ } else {
714
+ printErrorBox('GIT PUSH FAILED', [error.message]);
715
+ }
716
+ // When running from the interactive menu, return to it instead of exiting.
717
+ if (fromMenu) return 'back';
718
+ process.exit(1);
719
+ }
720
+ }