git-push-time 1.0.2 → 1.0.3

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/bin/index.js CHANGED
@@ -3,12 +3,17 @@
3
3
  import { Command } from 'commander';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
- import { input, select, confirm } from '@inquirer/prompts';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { input, select } from '@inquirer/prompts';
7
9
  import { isValidGitUrl, parseCustomDate } from '../lib/utils.js';
8
10
  import {
9
11
  checkGitInstalled,
10
12
  isGitRepo,
11
13
  initGitRepo,
14
+ getGitTopLevel,
15
+ listSubdirectories,
16
+ resolveTargetDir,
12
17
  setGitRemote,
13
18
  stageAllFiles,
14
19
  hasChangesToCommit,
@@ -16,7 +21,11 @@ import {
16
21
  setBranchName,
17
22
  pushToRemote,
18
23
  getCurrentBranch,
19
- findRiskyStagedFiles
24
+ findRiskyStagedFiles,
25
+ pullFromRemote,
26
+ pullRebaseFromRemote,
27
+ getRecentCommits,
28
+ rewriteCommitDate
20
29
  } from '../lib/git.js';
21
30
  import { scanStagedContentForSecrets } from '../lib/secrets.js';
22
31
  import {
@@ -26,18 +35,13 @@ import {
26
35
  getUnpushedCommits,
27
36
  squashUnpushedCommits
28
37
  } from '../lib/fix.js';
29
- import {
30
- checkNpmInstalled,
31
- getPackageInfo,
32
- checkNpmLogin,
33
- isNpmrcTracked,
34
- publishToNpm
35
- } from '../lib/npm.js';
36
38
  import {
37
39
  printBanner,
38
40
  printSummary,
39
41
  printSection,
40
42
  stepLabel,
43
+ printTargetDir,
44
+ printNestedRepoWarning,
41
45
  printPrePushSecretWarning,
42
46
  printSecretBlockedBox,
43
47
  printSuccessBox,
@@ -47,160 +51,468 @@ import {
47
51
  printAuthFailedBox,
48
52
  printNonFastForwardBox,
49
53
  printFixResult,
50
- printNpmResult,
51
54
  printFooter,
52
55
  styleCliHelp
53
56
  } from '../lib/ui.js';
54
57
 
55
- const VERSION = '1.0.2';
58
+ const VERSION = '1.0.3';
56
59
  const TOTAL_STEPS = 6;
57
60
 
58
- async function run(options = {}) {
59
- printBanner(VERSION);
61
+ /* ==========================================================================
62
+ BACK-NAVIGATION HELPERS — moi buoc chon/nhap deu co nut Quay lai.
63
+ Quy uoc: go ".." trong o nhap = Quay lai (tuong duong nut « Quay lai).
64
+ Khong dung emoji — an tuong bang chu in hoa + vien + mau.
65
+ ========================================================================== */
66
+ const BACK = '__back__';
67
+ const BACK_TOKENS = new Set(['..', '/back', '<<']);
60
68
 
61
- // 1. Kiểm tra Git
62
- if (!checkGitInstalled()) {
63
- printBanner(VERSION);
64
- printGitMissing();
65
- process.exit(1);
66
- }
69
+ function isBackInput(v) {
70
+ return BACK_TOKENS.has(String(v || '').trim());
71
+ }
67
72
 
68
- let repoUrl = options.repo;
69
- let commitMsg = options.message;
70
- let dateInput = options.date;
71
- let branchName = options.branch || 'main';
73
+ async function selectWithBack({ message, choices, pageSize, ...rest }) {
74
+ const hasBack = (choices || []).some((c) => c && c.value === BACK);
75
+ const finalChoices = hasBack
76
+ ? choices
77
+ : [
78
+ ...choices,
79
+ { name: '« Quay lại (bước trước)', value: BACK, description: 'Về bước trước / menu chính' }
80
+ ];
81
+ return await select({
82
+ message,
83
+ choices: finalChoices,
84
+ pageSize: pageSize || Math.min(12, finalChoices.length + 2),
85
+ ...rest
86
+ });
87
+ }
72
88
 
73
- const isInteractive = !options.yes;
89
+ async function inputWithBack({ message, validate, ...rest }) {
90
+ const hinted = `${message} [gõ ".." để quay lại]`;
91
+ const wrappedValidate = (val) => {
92
+ if (isBackInput(val)) return true;
93
+ if (validate) return validate(val);
94
+ return true;
95
+ };
96
+ const val = await input({ message: hinted, validate: wrappedValidate, ...rest });
97
+ if (isBackInput(val)) return BACK;
98
+ return val;
99
+ }
74
100
 
75
- // 2. Thu thập thông tin
76
- if (isInteractive) {
77
- printSection('🔗 KẾT NỐI REPOSITORY');
101
+ /**
102
+ * Cho user chọn đúng folder nguồn cần push (tránh up nhầm cả folder gốc ngoài cùng).
103
+ * Luon co nut « Quay lai de ve menu chinh.
104
+ * @param {string} startDir thư mục chạy lệnh
105
+ * @returns {Promise<string>} đường dẫn tuyệt đối của folder được chọn, hoac BACK
106
+ */
107
+ async function promptForTargetDir(startDir) {
108
+ const subs = listSubdirectories(startDir);
109
+ if (subs.length === 0) return startDir;
110
+
111
+ printSection('CHỌN FOLDER NGUỒN ĐỂ PUSH');
112
+ printTargetDir(startDir);
113
+
114
+ const picked = await selectWithBack({
115
+ message: 'Folder nào sẽ được đẩy lên Git?',
116
+ pageSize: Math.min(12, subs.length + 3),
117
+ choices: [
118
+ { name: '» Thư mục hiện tại (.)', value: startDir, description: 'Push toàn bộ thư mục hiện tại' },
119
+ ...subs.map((s) => ({
120
+ name: `» ${s.name}`,
121
+ value: s.fullPath,
122
+ description: s.fullPath
123
+ })),
124
+ { name: '» Nhập đường dẫn khác...', value: '__custom__', description: 'Gõ tay đường dẫn tuyệt đối/tương đối' }
125
+ ]
126
+ });
78
127
 
79
- if (!repoUrl) {
80
- repoUrl = await input({
81
- message: '📦 Dán link Git Repository:',
82
- validate: (value) => {
83
- if (!value || !value.trim()) return 'Vui lòng nhập đường dẫn repository!';
84
- if (!isValidGitUrl(value)) return 'URL chưa đúng (vd: https://github.com/user/repo.git)';
85
- return true;
86
- }
87
- });
88
- }
128
+ if (picked === BACK) return BACK;
129
+ if (picked !== '__custom__') return picked;
89
130
 
90
- if (!commitMsg) {
91
- commitMsg = await input({
92
- message: '💬 Thông điệp commit:',
93
- default: 'Initial commit'
94
- });
131
+ const customPath = await inputWithBack({
132
+ message: 'Nhập đường dẫn folder nguồn:',
133
+ validate: (value) => {
134
+ const r = resolveTargetDir(value, startDir);
135
+ return r.ok || r.error;
95
136
  }
137
+ });
138
+ if (customPath === BACK) return BACK;
139
+ return resolveTargetDir(customPath, startDir).fullPath;
140
+ }
96
141
 
97
- if (!dateInput) {
98
- printSection('⏰ THỜI GIAN COMMIT');
99
- const dateOption = await select({
100
- message: 'Chọn thời gian cho commit:',
101
- choices: [
102
- { name: '⏰ Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
103
- { name: '⏳ Lùi thời gian', value: 'relative', description: 'VD: -2d, -5h, -30m, -1w' },
104
- { name: '📅 Ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
105
- ]
106
- });
142
+ /**
143
+ * Nếu folder được chọn nằm trong Git repo của folder CHA mà chưa có .git riêng,
144
+ * hỏi user muốn tách repo riêng (chỉ push folder này) hay dùng chung repo cha.
145
+ * Luon co nut « Quay lai de chon lai folder.
146
+ * @returns {Promise<string>} 'separate' | 'shared' | BACK
147
+ */
148
+ async function handleNestedRepo(targetDir, isInteractive, options) {
149
+ const topLevel = getGitTopLevel(targetDir);
150
+ if (!topLevel) return; // chưa có repo nào -> bước init sẽ tạo mới, an toàn
151
+ if (path.resolve(topLevel) === path.resolve(targetDir)) return; // đã là root repo, ok
152
+ if (fs.existsSync(path.join(targetDir, '.git'))) return; // đã có repo riêng, ok
153
+
154
+ // Nằm trong repo cha -> cảnh báo bằng card theo đúng style UI chung
155
+ printNestedRepoWarning({ targetDir, topLevel });
156
+
157
+ let choice = 'separate';
158
+ if (isInteractive && !options.yes) {
159
+ choice = await selectWithBack({
160
+ message: 'Bạn muốn push thế nào?',
161
+ choices: [
162
+ { 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ó' },
163
+ { name: '» Dùng chung repo của folder cha', value: 'shared', description: 'Giữ nguyên, push theo repo cha' },
164
+ { name: 'x Hủy để kiểm tra lại', value: 'abort', description: 'Thoát, không làm gì' }
165
+ ]
166
+ });
167
+ if (choice === BACK) return BACK;
168
+ } else if (options.dir) {
169
+ // Chế độ -y + có --dir: mặc định tách repo riêng cho đúng ý "chọn đúng folder"
170
+ choice = 'separate';
171
+ } else {
172
+ // Chế độ -y ở thư mục hiện tại: giữ hành vi cũ, không tự tách
173
+ return;
174
+ }
107
175
 
108
- if (dateOption === 'now') {
109
- dateInput = 'now';
110
- } else if (dateOption === 'relative') {
111
- dateInput = await input({
112
- message: '⏳ Nhập thời gian lùi (VD: -2d, -5h, -30m, -1w):',
113
- default: '-1d',
114
- validate: (val) => {
115
- const parsed = parseCustomDate(val);
116
- return parsed.valid || parsed.error || 'Thời gian không hợp lệ';
117
- }
118
- });
119
- } else if (dateOption === 'custom') {
120
- dateInput = await input({
121
- message: '📅 Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
122
- default: '2024-01-15 10:00:00',
123
- validate: (val) => {
124
- const parsed = parseCustomDate(val);
125
- return parsed.valid || parsed.error || 'Ngày giờ không hợp lệ';
126
- }
127
- });
128
- }
129
- }
176
+ if (choice === 'abort') {
177
+ printCancelled();
178
+ process.exit(0);
179
+ }
180
+ if (choice === 'separate') {
181
+ initGitRepo(targetDir);
182
+ console.log(chalk.hex('#34D399')(' [OK] Đã tạo Git repo riêng cho folder này.\n'));
183
+ } else {
184
+ console.log(chalk.hex('#64748B')(' » Dùng chung repo cha (chỉ stage file trong folder này).\n'));
185
+ }
186
+ }
130
187
 
131
- if (!options.branch) {
132
- const currentB = getCurrentBranch();
133
- branchName = await input({
134
- message: '🌿 Branch trên remote:',
135
- default: currentB || 'main'
136
- });
188
+ /**
189
+ * Menu chính khi chạy `git-push-time` mà không truyền flag/subcommand.
190
+ * @returns {Promise<string>} 'push' | 'pull' | 'edit-time' | 'fix' | 'exit'
191
+ */
192
+ async function promptMainAction() {
193
+ printSection('CHỌN HÀNH ĐỘNG');
194
+ return await select({
195
+ message: 'Bạn muốn làm gì?',
196
+ choices: [
197
+ { name: '» PUSH | Đẩy code lên remote', value: 'push', description: 'git add + commit + push lên remote' },
198
+ { name: '» PULL | Kéo code về từ remote', value: 'pull', description: 'git pull --ff-only từ remote' },
199
+ { name: '» EDIT | Sửa thời gian commit', value: 'edit-time', description: 'Đổi Author/Committer Date của commit' },
200
+ { name: '» FIX | Quét + gỡ secret', value: 'fix', description: 'Quét + gỡ secret, làm sạch commit chưa push' },
201
+ { name: 'x THOÁT | Không làm gì', value: 'exit', description: 'Thoát, không làm gì cả' }
202
+ ]
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Hỏi Tiếp tục / Quay lại menu / Thoát sau mỗi tác vụ hoặc khi gặp lỗi.
208
+ * @param {string} message
209
+ * @returns {Promise<string>} 'continue' | 'back' | 'exit'
210
+ */
211
+ async function promptContinueOrBack(message = 'Bạn muốn tiếp tục hay quay lại?') {
212
+ return await select({
213
+ message,
214
+ choices: [
215
+ { name: '» Tiếp tục', value: 'continue', description: 'Đi tiếp bước kế tiếp' },
216
+ { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn hành động' },
217
+ { name: 'x Thoát', value: 'exit', description: 'Dừng, không làm gì' }
218
+ ]
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Kiểm tra lệnh có được gọi "trần" (không flag push nào) hay không.
224
+ * Chỉ khi gọi trần + interactive mới hiện menu chính để không phá hành vi cũ (-y / -r / -m...).
225
+ */
226
+ function isBarePushInvocation(options = {}) {
227
+ return !options.repo && !options.message && !options.date && !options.branch && !options.dir && !options.force && !options.fix;
228
+ }
229
+
230
+ /**
231
+ * Vòng lặp menu chính: Push / Pull / Edit-time / Fix.
232
+ * Sau mỗi tác vụ xong sẽ hỏi Tiếp tục (ở lại menu) hay Thoát.
233
+ */
234
+ async function runMainMenuLoop() {
235
+ while (true) {
236
+ const action = await promptMainAction();
237
+ if (action === 'exit') {
238
+ printCancelled();
239
+ process.exit(0);
240
+ }
241
+ if (action === 'push') {
242
+ return; // thoát loop để chảy tiếp xuống flow push bên dưới
243
+ }
244
+ if (action === 'pull') {
245
+ await runPullCommand('origin', undefined, { fromMenu: true });
246
+ } else if (action === 'edit-time') {
247
+ await runEditCommitTimeCommand({ fromMenu: true });
248
+ } else if (action === 'fix') {
249
+ await runFixCommand({ fromMenu: true });
250
+ }
251
+ // Sau khi xong 1 tác vụ (pull/edit/fix), hỏi ở lại menu hay thoát.
252
+ const next = await promptContinueOrBack('Hoàn tất! Bạn muốn tiếp tục hay quay lại?');
253
+ if (next === 'exit') {
254
+ process.exit(0);
255
+ }
256
+ if (next === 'continue' || next === 'back') {
257
+ continue; // quay lại menu chính
137
258
  }
138
259
  }
260
+ }
139
261
 
140
- if (!commitMsg) commitMsg = 'Initial commit';
141
- if (!dateInput) dateInput = 'now';
142
- if (!branchName) branchName = 'main';
262
+ async function run(options = {}) {
263
+ printBanner(VERSION);
143
264
 
144
- if (!repoUrl || !isValidGitUrl(repoUrl)) {
145
- printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [
146
- `URL nhận được: '${repoUrl || '(trống)'}'`,
147
- ], 'Ví dụ đúng: https://github.com/username/repository.git');
265
+ // 1. Kiểm tra Git
266
+ if (!checkGitInstalled()) {
267
+ printBanner(VERSION);
268
+ printGitMissing();
148
269
  process.exit(1);
149
270
  }
150
271
 
151
- const parsedDate = parseCustomDate(dateInput);
152
- if (!parsedDate.valid) {
153
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
154
- process.exit(1);
272
+ const isInteractive = !options.yes;
273
+
274
+ // 1a. Nếu chạy trần `git-push-time` (không flag, không subcommand) thì hiện menu chính:
275
+ // Push / Pull / Sửa thời gian / Fix secret — bổ sung lựa chọn Pull còn thiếu.
276
+ if (isInteractive && isBarePushInvocation(options)) {
277
+ await runMainMenuLoop();
278
+ // Người dùng đã chọn "Đẩy code lên (Push)" -> chảy tiếp xuống flow push bên dưới.
155
279
  }
156
280
 
157
- // Tóm tắt
158
- printSummary({
159
- repoUrl,
160
- commitMsg,
161
- dateDisplay: parsedDate.display,
162
- branch: branchName,
163
- force: options.force
164
- });
281
+ const startDir = process.cwd();
165
282
 
166
- if (isInteractive) {
167
- const ok = await confirm({
168
- message: '🚀 Xác nhận đẩy lên Git ngay?',
169
- default: true
170
- });
171
- if (!ok) {
172
- printCancelled();
173
- process.exit(0);
283
+ // Wizard Push theo tung buoc — moi buoc deu co nut « Quay lai.
284
+ // Buoc 0: folder | 1: repoUrl | 2: commitMsg | 3: date | 4: branch | 5: xac nhan
285
+ let targetDir = startDir;
286
+ let repoUrl = options.repo;
287
+ let commitMsg = options.message;
288
+ let dateInput = options.date;
289
+ let branchName = options.branch;
290
+ let parsedDate = null;
291
+
292
+ if (options.dir) {
293
+ const r = resolveTargetDir(options.dir, startDir);
294
+ if (!r.ok) {
295
+ printErrorBox('THƯ MỤC NGUỒN KHÔNG HỢP LỆ', [r.error], 'VD: --dir ./my-app hoặc --dir "E:/projects/my-app"');
296
+ process.exit(1);
297
+ }
298
+ targetDir = r.fullPath;
299
+ }
300
+
301
+ if (!isInteractive) {
302
+ if (!commitMsg) commitMsg = 'Initial commit';
303
+ if (!dateInput) dateInput = 'now';
304
+ if (!branchName) branchName = 'main';
305
+ if (!repoUrl || !isValidGitUrl(repoUrl)) {
306
+ printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
307
+ process.exit(1);
308
+ }
309
+ parsedDate = parseCustomDate(dateInput);
310
+ if (!parsedDate.valid) {
311
+ printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
312
+ process.exit(1);
313
+ }
314
+ printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
315
+ } else {
316
+ let step = options.dir ? 1 : 0;
317
+ const cameFromMenu = isBarePushInvocation(options);
318
+ while (true) {
319
+ // Buoc 0: chon folder
320
+ if (step === 0) {
321
+ const picked = await promptForTargetDir(startDir);
322
+ if (picked === BACK) {
323
+ if (cameFromMenu) {
324
+ await runMainMenuLoop();
325
+ // Menu tra ve nghia la user lai chon Push -> o lai buoc 0 de chon lai folder
326
+ if (!options.repo) repoUrl = undefined;
327
+ if (!options.message) commitMsg = undefined;
328
+ if (!options.date) dateInput = undefined;
329
+ if (!options.branch) branchName = undefined;
330
+ continue;
331
+ }
332
+ printCancelled();
333
+ process.exit(0);
334
+ }
335
+ targetDir = picked;
336
+ const nested = await handleNestedRepo(targetDir, isInteractive, options);
337
+ if (nested === BACK) continue; // quay lai chon folder
338
+ step = 1;
339
+ continue;
340
+ }
341
+ // Bước 1: repo URL
342
+ if (step === 1) {
343
+ if (options.repo) { repoUrl = options.repo; step = 2; continue; }
344
+ if (!repoUrl) {
345
+ printSection('KẾT NỐI REPOSITORY');
346
+ const val = await inputWithBack({
347
+ message: 'Dán link Git Repository:',
348
+ validate: (value) => {
349
+ if (!value || !value.trim()) return 'Vui lòng nhập đường dẫn repository!';
350
+ if (!isValidGitUrl(value)) return 'URL chưa đúng (vd: https://github.com/user/repo.git)';
351
+ return true;
352
+ }
353
+ });
354
+ if (val === BACK) {
355
+ // Về bước chọn folder (hoặc về menu nếu có --dir + chạy từ menu)
356
+ if (options.dir) {
357
+ if (cameFromMenu) { await runMainMenuLoop(); continue; }
358
+ step = 1;
359
+ } else { step = 0; }
360
+ continue;
361
+ }
362
+ repoUrl = val;
363
+ }
364
+ if (!repoUrl || !isValidGitUrl(repoUrl)) {
365
+ printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
366
+ const retry = await promptContinueOrBack('URL chưa đúng. Bạn muốn nhập lại hay quay lại?');
367
+ if (retry === 'exit') { printCancelled(); process.exit(0); }
368
+ repoUrl = options.repo;
369
+ if (retry === 'back') {
370
+ if (cameFromMenu) await runMainMenuLoop();
371
+ step = options.dir ? 1 : 0;
372
+ }
373
+ continue;
374
+ }
375
+ step = 2;
376
+ continue;
377
+ }
378
+ // Bước 2: commit message
379
+ if (step === 2) {
380
+ if (options.message) { commitMsg = options.message; step = 3; continue; }
381
+ if (!commitMsg) {
382
+ const val = await inputWithBack({ message: 'Thông điệp commit:', default: 'Initial commit' });
383
+ if (val === BACK) { step = 1; if (!options.repo) repoUrl = undefined; continue; }
384
+ commitMsg = val || 'Initial commit';
385
+ }
386
+ step = 3;
387
+ continue;
388
+ }
389
+ // Bước 3: thời gian
390
+ if (step === 3) {
391
+ if (options.date) { dateInput = options.date; step = 4; continue; }
392
+ if (!dateInput) {
393
+ printSection('THỜI GIAN COMMIT');
394
+ const dateOption = await selectWithBack({
395
+ message: 'Chọn thời gian cho commit:',
396
+ choices: [
397
+ { name: '» Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
398
+ { name: '» Lùi thời gian', value: 'relative', description: 'VD: -2d, -5h, -30m, -1w' },
399
+ { name: '» Ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
400
+ ]
401
+ });
402
+ if (dateOption === BACK) { step = 2; if (!options.message) commitMsg = undefined; continue; }
403
+ if (dateOption === 'now') {
404
+ dateInput = 'now';
405
+ } else if (dateOption === 'relative') {
406
+ const val = await inputWithBack({
407
+ message: 'Nhập thời gian lùi (VD: -2d, -5h, -30m, -1w):',
408
+ default: '-1d',
409
+ validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Thời gian không hợp lệ'; }
410
+ });
411
+ if (val === BACK) continue; // quay lại chọn kiểu thời gian
412
+ dateInput = val;
413
+ } else if (dateOption === 'custom') {
414
+ const val = await inputWithBack({
415
+ message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
416
+ default: '2024-01-15 10:00:00',
417
+ validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Ngày giờ không hợp lệ'; }
418
+ });
419
+ if (val === BACK) continue;
420
+ dateInput = val;
421
+ }
422
+ }
423
+ parsedDate = parseCustomDate(dateInput || 'now');
424
+ if (!parsedDate.valid) {
425
+ printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
426
+ const retry = await promptContinueOrBack('Thời gian chưa đúng. Bạn muốn nhập lại hay quay lại?');
427
+ if (retry === 'exit') { printCancelled(); process.exit(0); }
428
+ dateInput = options.date;
429
+ if (retry === 'back') { step = 2; if (!options.message) commitMsg = undefined; }
430
+ continue;
431
+ }
432
+ if (!dateInput) dateInput = 'now';
433
+ step = 4;
434
+ continue;
435
+ }
436
+ // Bước 4: branch
437
+ if (step === 4) {
438
+ if (options.branch) { branchName = options.branch; step = 5; continue; }
439
+ if (!branchName) {
440
+ const currentB = getCurrentBranch(targetDir);
441
+ const val = await inputWithBack({ message: 'Branch trên remote:', default: currentB || 'main' });
442
+ if (val === BACK) { step = 3; if (!options.date) dateInput = undefined; continue; }
443
+ branchName = val || currentB || 'main';
444
+ }
445
+ if (!branchName) branchName = 'main';
446
+ step = 5;
447
+ continue;
448
+ }
449
+ // Bước 5: xác nhận — có nút quay về từng bước trước
450
+ if (step === 5) {
451
+ if (!commitMsg) commitMsg = 'Initial commit';
452
+ if (!dateInput) dateInput = 'now';
453
+ if (!branchName) branchName = 'main';
454
+ parsedDate = parseCustomDate(dateInput);
455
+ printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
456
+ const decision = await select({
457
+ message: 'Xác nhận đẩy lên Git ngay?',
458
+ choices: [
459
+ { name: '» Tiếp tục đẩy', value: 'continue', description: 'Bắt đầu git init/add/commit/push' },
460
+ { name: '« Quay lại sửa branch', value: 'back-branch', description: 'Về bước nhập branch' },
461
+ { name: '« Quay lại sửa thời gian', value: 'back-date', description: 'Về bước chọn thời gian' },
462
+ { name: '« Quay lại sửa message', value: 'back-msg', description: 'Về bước nhập message' },
463
+ { name: '« Quay lại sửa repo URL', value: 'back-url', description: 'Về bước nhập URL' },
464
+ { name: '« Quay lại sửa folder', value: 'back-folder', description: 'Về bước chọn folder' },
465
+ { name: '« Quay lại menu chính', value: 'back-menu', description: 'Đổi sang Pull / Sửa thời gian / Fix' },
466
+ { name: 'x Hủy', value: 'exit', description: 'Thoát, không làm gì' }
467
+ ]
468
+ });
469
+ if (decision === 'exit') { printCancelled(); process.exit(0); }
470
+ if (decision === 'continue') break;
471
+ if (decision === 'back-branch') { if (!options.branch) branchName = undefined; step = 4; continue; }
472
+ if (decision === 'back-date') { if (!options.date) dateInput = undefined; step = 3; continue; }
473
+ if (decision === 'back-msg') { if (!options.message) commitMsg = undefined; step = 2; continue; }
474
+ if (decision === 'back-url') { if (!options.repo) repoUrl = undefined; step = 1; continue; }
475
+ if (decision === 'back-folder') { step = options.dir ? 1 : 0; if (options.dir && cameFromMenu) { await runMainMenuLoop(); } continue; }
476
+ if (decision === 'back-menu') {
477
+ await runMainMenuLoop();
478
+ if (!options.repo) repoUrl = undefined;
479
+ if (!options.message) commitMsg = undefined;
480
+ if (!options.date) dateInput = undefined;
481
+ if (!options.branch) branchName = undefined;
482
+ step = options.dir ? 1 : 0;
483
+ continue;
484
+ }
485
+ }
174
486
  }
175
487
  }
176
488
 
177
489
  // 3. Thực thi
178
490
  const spinner = ora({ color: 'cyan' });
179
- printSection(`⚙️ ĐANG TRIỂN KHAI (${TOTAL_STEPS} bước)`);
491
+ printSection(`ĐANG TRIỂN KHAI (${TOTAL_STEPS} bước)`);
180
492
 
181
493
  try {
182
494
  // [1/6] Git init
183
- if (!isGitRepo()) {
184
- spinner.start(stepLabel(1, TOTAL_STEPS, 'Khởi tạo Git repo (git init)…'));
185
- initGitRepo();
495
+ if (!isGitRepo(targetDir)) {
496
+ spinner.start(stepLabel(1, TOTAL_STEPS, 'Khởi tạo Git repo (git init)...'));
497
+ initGitRepo(targetDir);
186
498
  spinner.succeed(stepLabel(1, TOTAL_STEPS, chalk.hex('#34D399')('Đã khởi tạo Git repo')));
187
499
  } else {
188
500
  console.log(' ' + stepLabel(1, TOTAL_STEPS, chalk.hex('#64748B')('Đã có Git repo sẵn — bỏ qua init')));
189
501
  }
190
502
 
191
503
  // [2/6] Remote
192
- spinner.start(stepLabel(2, TOTAL_STEPS, 'Cấu hình remote origin…'));
193
- setGitRemote(repoUrl);
504
+ spinner.start(stepLabel(2, TOTAL_STEPS, 'Cấu hình remote origin...'));
505
+ setGitRemote(repoUrl, targetDir);
194
506
  spinner.succeed(stepLabel(2, TOTAL_STEPS, chalk.hex('#34D399')('Cấu hình remote origin hoàn tất')));
195
507
 
196
508
  // [3/6] Stage
197
- spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)…'));
198
- stageAllFiles();
509
+ spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)...'));
510
+ stageAllFiles(targetDir);
199
511
  spinner.succeed(stepLabel(3, TOTAL_STEPS, chalk.hex('#34D399')('Đã stage toàn bộ files')));
200
512
 
201
513
  // [3.5] Quét secret: tên file + nội dung
202
- const risky = findRiskyStagedFiles();
203
- const findings = scanStagedContentForSecrets();
514
+ const risky = findRiskyStagedFiles(targetDir);
515
+ const findings = scanStagedContentForSecrets(targetDir);
204
516
  if (risky.length > 0 || findings.length > 0) {
205
517
  spinner.stop();
206
518
  printPrePushSecretWarning({ riskyFiles: risky, findings });
@@ -208,110 +520,115 @@ async function run(options = {}) {
208
520
 
209
521
  let wantFix = options.fix === true;
210
522
  if (!wantFix && isInteractive) {
211
- wantFix = await confirm({
212
- message: `🛠️ Tự động fix giúp bạn? (git rm --cached + thêm .gitignore, giữ file ở máy)`,
213
- default: true
523
+ const fixChoice = await selectWithBack({
524
+ message: 'Tự động fix giúp bạn? (git rm --cached + thêm .gitignore, giữ file ở máy)',
525
+ choices: [
526
+ { name: '» Có, tự động fix', value: 'yes', description: 'Gỡ file secret khỏi stage' },
527
+ { name: '» Không, giữ nguyên', value: 'no', description: 'Tiếp tục mà không fix' }
528
+ ]
214
529
  });
530
+ if (fixChoice === BACK) {
531
+ console.log(chalk.hex('#64748B')(' » Bạn chọn quay lại. Hủy push để chạy lại từ bước xác nhận.\n'));
532
+ printCancelled();
533
+ process.exit(0);
534
+ }
535
+ wantFix = fixChoice === 'yes';
215
536
  }
216
537
 
217
538
  if (wantFix) {
218
- spinner.start('Đang tự fix: gỡ file secret khỏi stage…');
219
- const res = autoFixStagedSecrets(allFiles);
220
- spinner.succeed(chalk.hex('#34D399')(`✓ Đã gỡ ${res.fixed.length} file khỏi commit`));
539
+ spinner.start('Đang tự fix: gỡ file secret khỏi stage...');
540
+ const res = autoFixStagedSecrets(allFiles, targetDir);
541
+ spinner.succeed(chalk.hex('#34D399')(`[OK] Đã gỡ ${res.fixed.length} file khỏi commit`));
221
542
  printFixResult(res);
222
543
 
223
544
  // Đảm bảo .gitignore được stage trước khi kiểm tra hasStagedChanges
224
545
  try {
225
546
  const { execSync } = await import('child_process');
226
- execSync('git add .gitignore', { stdio: 'ignore' });
547
+ execSync('git add .gitignore', { cwd: targetDir, stdio: 'ignore' });
227
548
  } catch { /* bỏ qua */ }
228
549
 
229
- if (!hasStagedChanges()) {
550
+ if (!hasStagedChanges(targetDir)) {
230
551
  printErrorBox('KHÔNG CÒN FILE ĐỂ COMMIT', [
231
552
  'Sau khi gỡ secret thì không còn file nào để commit.',
232
553
  'File secret vẫn nằm ở máy bạn, chỉ là không push lên nữa.',
233
554
  ]);
234
555
  process.exit(0);
235
556
  }
236
- console.log(chalk.hex('#34D399')(' → Tiếp tục commit/push phần sạch còn lại.\n'));
557
+ console.log(chalk.hex('#34D399')(' » Tiếp tục commit/push phần sạch còn lại.\n'));
237
558
  } else if (isInteractive) {
238
- const keepGoing = await confirm({
559
+ const keepChoice = await selectWithBack({
239
560
  message: findings.length > 0
240
- ? `⚠️ Phát hiện ${findings.length} vị trí giống secret. Vẫn tiếp tục mà không fix?`
561
+ ? `Phát hiện ${findings.length} vị trí giống secret. Vẫn tiếp tục mà không fix?`
241
562
  : 'Vẫn tiếp tục commit/push với các file trên mà không fix?',
242
- default: false
563
+ choices: [
564
+ { name: '» Vẫn tiếp tục (chịu rủi ro)', value: 'yes', description: 'Tiếp tục mà không fix' },
565
+ { name: '» Dừng lại để fix tay', value: 'no', description: 'Dừng để xử lý' }
566
+ ]
243
567
  });
244
- if (!keepGoing) {
245
- console.log(chalk.cyan('\n 🛠️ Chạy lại với --fix để tool tự gỡ giúp bạn, hoặc làm tay:'));
568
+ const keepGoing = keepChoice === 'yes';
569
+ if (keepChoice === BACK || !keepGoing) {
570
+ console.log(chalk.cyan('\n » Chạy lại với --fix để tool tự gỡ giúp bạn, hoặc làm tay:'));
246
571
  allFiles.forEach((f) => console.log(chalk.white(` git rm --cached "${f}"`)));
247
572
  console.log(chalk.gray(' Sau đó chạy lại tool.\n'));
248
573
  process.exit(0);
249
574
  }
250
- console.log(chalk.yellow(' → Bạn chọn tiếp tục với file nhạy cảm. Nếu push bị chặn, tool sẽ hướng dẫn tiếp.\n'));
575
+ console.log(chalk.yellow(' » Bạn chọn tiếp tục với file nhạy cảm. Nếu push bị chặn, tool sẽ hướng dẫn tiếp.\n'));
251
576
  } else {
252
- console.log(chalk.yellow(' ⚠️ Chế độ -y: vẫn tiếp tục dù phát hiện file nhạy cảm. Dùng --fix để tự gỡ.\n'));
577
+ console.log(chalk.yellow(' [!] Chế độ -y: vẫn tiếp tục dù phát hiện file nhạy cảm. Dùng --fix để tự gỡ.\n'));
253
578
  }
254
579
  }
255
580
 
256
581
  // [4/6] Commit
257
- spinner.start(stepLabel(4, TOTAL_STEPS, `Tạo commit [${parsedDate.display}]…`));
258
- if (!hasChangesToCommit()) {
582
+ spinner.start(stepLabel(4, TOTAL_STEPS, `Tạo commit [${parsedDate.display}]...`));
583
+ if (!hasChangesToCommit(targetDir)) {
259
584
  spinner.info(stepLabel(4, TOTAL_STEPS, chalk.hex('#94A3B8')('Không có thay đổi mới — dùng commit hiện có')));
260
585
  } else {
261
- commitWithCustomDate(commitMsg, parsedDate.formatted);
586
+ commitWithCustomDate(commitMsg, parsedDate.formatted, targetDir);
262
587
  spinner.succeed(stepLabel(4, TOTAL_STEPS, chalk.hex('#34D399')(`Commit hoàn tất • ${chalk.hex('#C084FC').bold(parsedDate.display)}`)));
263
588
  }
264
589
 
265
590
  // [5/6] Branch
266
- spinner.start(stepLabel(5, TOTAL_STEPS, `Đổi branch → '${branchName}'…`));
267
- setBranchName(branchName);
591
+ spinner.start(stepLabel(5, TOTAL_STEPS, `Đổi branch -> '${branchName}'...`));
592
+ setBranchName(branchName, targetDir);
268
593
  spinner.succeed(stepLabel(5, TOTAL_STEPS, chalk.hex('#34D399')(`Branch: ${chalk.hex('#5EEAD4').bold(branchName)}`)));
269
594
 
270
595
  // [6/6] Push
271
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lên origin/${branchName}…`));
272
- pushToRemote(branchName, options.force);
596
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Push lên origin/${branchName}...`));
597
+ pushToRemote(branchName, options.force, targetDir);
273
598
  spinner.succeed(stepLabel(6, TOTAL_STEPS, chalk.bold.hex('#34D399')('Push lên remote thành công!')));
274
599
 
275
600
  printSuccessBox({ branch: branchName, repoUrl });
276
601
 
277
- // [+1] Publish npm (optional)
278
- let wantPublish = options.publish === true;
279
- if (!wantPublish && isInteractive) {
280
- wantPublish = await confirm({
281
- message: '📦 Publish package này lên npm luôn không?',
282
- default: false
283
- });
284
- }
285
- if (wantPublish) {
286
- await runNpmPublish({ access: options.access, dryRun: options.dryRun, isInteractive });
287
- } else {
288
- printFooter();
289
- }
602
+ printFooter();
290
603
  } catch (error) {
291
604
  spinner.stop();
292
605
  if (error.code === 'SECRET_BLOCKED') {
293
606
  printSecretBlockedBox(error.message, error.details || {});
294
607
 
295
608
  // Kiểm tra xem có commit unpushed chứa vết secret cũ không để hỗ trợ squash tự động
296
- const unpushed = getUnpushedCommits(branchName);
609
+ const unpushed = getUnpushedCommits(branchName, targetDir);
297
610
  if (unpushed.length > 0) {
298
- console.log(chalk.cyanBright(`\n 💡 Phát hiện bạn có ${unpushed.length} commit chưa push ở local có thể chứa vết secret cũ.`));
611
+ console.log(chalk.cyanBright(`\n » Phát hiện bạn có ${unpushed.length} commit chưa push ở local có thể chứa vết secret cũ.`));
299
612
  let doSquash = options.fix === true;
300
613
  if (!doSquash && isInteractive) {
301
- doSquash = await confirm({
302
- message: '🛠️ Tự động làm sạch lịch sử (squash các commit unpushed thành 1 commit sạch) để push lại?',
303
- default: true
614
+ const sqChoice = await selectWithBack({
615
+ message: 'Tự động làm sạch lịch sử (squash các commit unpushed thành 1 commit sạch) để push lại?',
616
+ choices: [
617
+ { name: '» Có, squash và push lại', value: 'yes', description: 'Làm sạch lịch sử' },
618
+ { name: '» Không, để tự xử lý', value: 'no', description: 'Bỏ qua' }
619
+ ]
304
620
  });
621
+ doSquash = sqChoice === 'yes';
305
622
  }
306
623
  if (doSquash) {
307
- spinner.start('Đang squash và làm sạch commit chưa push…');
308
- const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted);
624
+ spinner.start('Đang squash và làm sạch commit chưa push...');
625
+ const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted, targetDir);
309
626
  if (sqRes.ok) {
310
- spinner.succeed(chalk.green('✓ Đã làm sạch commit! Đang thử push lại…'));
627
+ spinner.succeed(chalk.green('[OK] Đã làm sạch commit! Đang thử push lại...'));
311
628
  try {
312
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}…`));
313
- pushToRemote(branchName, options.force);
314
- spinner.succeed(chalk.bold.green('✓ Push thành công'));
629
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
630
+ pushToRemote(branchName, options.force, targetDir);
631
+ spinner.succeed(chalk.bold.green('[OK] Push thành công'));
315
632
  printSuccessBox({ branch: branchName, repoUrl });
316
633
  printFooter();
317
634
  return;
@@ -327,6 +644,50 @@ async function run(options = {}) {
327
644
  printAuthFailedBox(error.message);
328
645
  } else if (error.code === 'NON_FAST_FORWARD') {
329
646
  printNonFastForwardBox(error.message, branchName);
647
+ if (isInteractive) {
648
+ const ffChoice = await select({
649
+ message: 'Remote đã có commit mới hơn. Bạn muốn xử lý thế nào?',
650
+ choices: [
651
+ { name: '» Pull về (rebase) rồi push lại — Tiếp tục', value: 'pull-push', description: `git pull --rebase origin ${branchName} rồi push lại` },
652
+ { name: '» Chỉ Pull về (chưa push vội)', value: 'pull-only', description: 'Chỉ đồng bộ code về máy' },
653
+ { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn Push / Pull / Fix' },
654
+ { name: 'x Thoát (tự xử lý tay)', value: 'exit', description: 'Không làm gì thêm' }
655
+ ]
656
+ });
657
+ if (ffChoice === 'pull-push' || ffChoice === 'pull-only') {
658
+ try {
659
+ spinner.start(`Đang pull --rebase origin/${branchName}...`);
660
+ const pullOut = pullRebaseFromRemote('origin', branchName, targetDir);
661
+ spinner.succeed(chalk.hex('#34D399')(`Đã pull --rebase origin/${branchName} xong`));
662
+ if (pullOut) console.log(chalk.hex('#64748B')(` ${String(pullOut).split('\n')[0].slice(0, 120)}`));
663
+ if (ffChoice === 'pull-push') {
664
+ spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
665
+ pushToRemote(branchName, options.force, targetDir);
666
+ spinner.succeed(chalk.bold.hex('#34D399')('Push lại thành công sau khi pull!'));
667
+ printSuccessBox({ branch: branchName, repoUrl });
668
+ printFooter();
669
+ return;
670
+ }
671
+ console.log(chalk.hex('#34D399')('\n [OK] Đã pull code mới nhất về. Chạy lại tool để push khi bạn sẵn sàng.\n'));
672
+ const afterPull = await promptContinueOrBack('Bạn muốn quay lại menu chính hay thoát?');
673
+ if (afterPull !== 'exit') {
674
+ await runMainMenuLoop();
675
+ }
676
+ return;
677
+ } catch (pullErr) {
678
+ spinner.fail(chalk.red(`Pull --rebase thất bại: ${String(pullErr.message).split('\n')[0]}`));
679
+ printErrorBox('PULL --REBASE THẤT BẠI', [String(pullErr.message).split('\n')[0]], 'Có thể bị conflict. Giải quyết conflict (git status) rồi chạy lại tool.');
680
+ const retry = await promptContinueOrBack('Pull lỗi. Bạn muốn quay lại menu hay thoát?');
681
+ if (retry !== 'exit') {
682
+ await runMainMenuLoop();
683
+ }
684
+ return;
685
+ }
686
+ } else if (ffChoice === 'back') {
687
+ await runMainMenuLoop();
688
+ return;
689
+ }
690
+ }
330
691
  } else if (error.code === 'REMOTE_OR_NETWORK') {
331
692
  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.');
332
693
  } else {
@@ -340,59 +701,78 @@ async function run(options = {}) {
340
701
  * Lệnh fix độc lập: `git-push-time fix`
341
702
  * Tự động quét và fix secret ở staged, working directory và unpushed commits.
342
703
  */
343
- async function runFixCommand() {
344
- printBanner(VERSION);
345
- printSection('🛠️ TỰ ĐỘNG FIX SECRET & BẢO VỆ REPO');
704
+ async function runFixCommand(options = {}) {
705
+ const fromMenu = options.fromMenu === true;
706
+ if (!fromMenu) printBanner(VERSION);
707
+ printSection('TỰ ĐỘNG FIX SECRET + BẢO VỆ REPO');
346
708
 
347
709
  if (!checkGitInstalled()) {
348
710
  printGitMissing();
711
+ if (fromMenu) return;
349
712
  process.exit(1);
350
713
  }
351
714
 
352
- if (!isGitRepo()) {
353
- printErrorBox('CHƯA PHẢI GIT REPOSITORY', ['Thư mục này chưa phải là Git repository.'], 'Chạy git init hoặc cd vào đúng thư mục dự án.');
715
+ let targetDir = process.cwd();
716
+ if (options.dir) {
717
+ const r = resolveTargetDir(options.dir, process.cwd());
718
+ if (!r.ok) {
719
+ printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
720
+ if (fromMenu) return;
721
+ process.exit(1);
722
+ }
723
+ targetDir = r.fullPath;
724
+ }
725
+ printTargetDir(targetDir);
726
+
727
+ if (!isGitRepo(targetDir)) {
728
+ printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải là Git repository.`], 'Chạy git init hoặc cd vào đúng thư mục dự án / dùng --dir.');
729
+ if (fromMenu) return;
354
730
  process.exit(1);
355
731
  }
356
732
 
357
733
  const spinner = ora({ color: 'cyan' });
358
734
  spinner.start('Đang kiểm tra staged files và thư mục làm việc…');
359
735
 
360
- const risky = findRiskyStagedFiles();
361
- const findings = scanStagedContentForSecrets();
362
- const riskyWorking = findRiskyWorkingTreeFiles();
736
+ const risky = findRiskyStagedFiles(targetDir);
737
+ const findings = scanStagedContentForSecrets(targetDir);
738
+ const riskyWorking = findRiskyWorkingTreeFiles(targetDir);
363
739
  spinner.stop();
364
740
 
365
741
  const allDetected = [...new Set([...risky, ...findings.map((f) => f.file), ...riskyWorking])];
366
742
 
367
743
  if (allDetected.length === 0) {
368
- printSection('✨ KẾT QUẢ QUÉT');
369
- console.log(chalk.hex('#34D399')(' ✨ Không phát hiện file secret mới ở stage hay thư mục làm việc!\n'));
744
+ printSection('KẾT QUẢ QUÉT');
745
+ console.log(chalk.hex('#34D399')(' [OK] Không phát hiện file secret mới ở stage hay thư mục làm việc!\n'));
370
746
  } else {
371
747
  printPrePushSecretWarning({ riskyFiles: allDetected, findings });
372
- spinner.start('Đang tự động fix (gỡ khỏi stage + thêm .gitignore)…');
373
- const res = autoFixStagedSecrets(allDetected);
374
- spinner.succeed(chalk.hex('#34D399')(`✓ Đã xử lý xong ${res.fixed.length} file!`));
748
+ spinner.start('Đang tự động fix (gỡ khỏi stage + thêm .gitignore)...');
749
+ const res = autoFixStagedSecrets(allDetected, targetDir);
750
+ spinner.succeed(chalk.hex('#34D399')(`[OK] Đã xử lý xong ${res.fixed.length} file!`));
375
751
  printFixResult(res);
376
752
  }
377
753
 
378
754
  // Kiểm tra xem có commit chưa push nào bị dính vết secret cũ không
379
- const currentBranch = getCurrentBranch();
380
- const unpushed = getUnpushedCommits(currentBranch);
755
+ const currentBranch = getCurrentBranch(targetDir);
756
+ const unpushed = getUnpushedCommits(currentBranch, targetDir);
381
757
  if (unpushed.length > 0) {
382
- printSection(`🔍 ${unpushed.length} COMMIT CHƯA PUSH`);
383
- unpushed.slice(0, 5).forEach((c) => console.log(chalk.hex('#94A3B8')(` • ${c}`)));
758
+ printSection(`${unpushed.length} COMMIT CHƯA PUSH`);
759
+ unpushed.slice(0, 5).forEach((c) => console.log(chalk.hex('#94A3B8')(` - ${c}`)));
384
760
  if (unpushed.length > 5) {
385
- console.log(chalk.hex('#64748B')(` …và ${unpushed.length - 5} commit khác`));
761
+ console.log(chalk.hex('#64748B')(` ...và ${unpushed.length - 5} commit khác`));
386
762
  }
387
- const okSquash = await confirm({
388
- message: `🛠️ Bạn có muốn gộp (squash) ${unpushed.length} commit chưa push thành 1 commit sạch để xóa dấu vết secret cũ không?`,
389
- default: true
763
+ const sqChoice = await selectWithBack({
764
+ message: `Bạn có muốn gộp (squash) ${unpushed.length} commit chưa push thành 1 commit sạch để xóa dấu vết secret cũ không? [chọn « Quay lại để bỏ qua]`,
765
+ choices: [
766
+ { name: '» Có, squash thành 1 commit sạch', value: 'yes', description: 'Làm sạch lịch sử' },
767
+ { name: '» Không, giữ nguyên', value: 'no', description: 'Bỏ qua' }
768
+ ]
390
769
  });
391
- if (okSquash) {
392
- spinner.start('Đang làm sạch lịch sử commit chưa push…');
393
- const sq = squashUnpushedCommits(currentBranch, 'Clean commit without secrets');
770
+ const okSquash = sqChoice === 'yes';
771
+ if (sqChoice !== BACK && okSquash) {
772
+ spinner.start('Đang làm sạch lịch sử commit chưa push...');
773
+ const sq = squashUnpushedCommits(currentBranch, 'Clean commit without secrets', null, targetDir);
394
774
  if (sq.ok) {
395
- spinner.succeed(chalk.hex('#34D399')('✓ Đã làm sạch commit chưa push! Sẵn sàng push lên remote.'));
775
+ spinner.succeed(chalk.hex('#34D399')('[OK] Đã làm sạch commit chưa push! Sẵn sàng push lên remote.'));
396
776
  } else {
397
777
  spinner.fail(chalk.red(`Không thể squash: ${sq.error}`));
398
778
  }
@@ -402,74 +782,180 @@ async function runFixCommand() {
402
782
  printSuccessBox({ branch: currentBranch || 'main', repoUrl: 'local repo đã sạch — sẵn sàng push' });
403
783
  }
404
784
 
405
- /**
406
- * Publish lên npm sau khi push Git xong.
407
- * An toàn: không nhận token qua flag, dùng `npm login` hoặc env NPM_TOKEN.
408
- * Nếu .npmrc đang bị track trong git thì cảnh báo (không in token).
409
- */
410
- async function runNpmPublish({ access = 'public', dryRun = false, isInteractive = true } = {}) {
411
- const spinner = ora({ color: 'magenta' });
412
- printSection('📦 PUBLISH LÊN NPM');
413
-
414
- if (!checkNpmInstalled()) {
415
- printErrorBox('CHƯA CÀI NPM', ['Chưa phát hiện npm trên máy.'], 'Cài Node.js từ https://nodejs.org rồi thử lại.');
416
- return;
785
+ async function runPullCommand(remote = 'origin', branch, options = {}) {
786
+ const fromMenu = options.fromMenu === true;
787
+ if (!fromMenu) printBanner(VERSION);
788
+ if (!checkGitInstalled()) {
789
+ printGitMissing();
790
+ if (fromMenu) return;
791
+ process.exit(1);
417
792
  }
418
- const pkg = getPackageInfo();
419
- if (!pkg) {
420
- printErrorBox('KHÔNG ĐỌC ĐƯỢC PACKAGE.JSON', ['Không tìm thấy package.json ở thư mục hiện tại.'], 'cd vào đúng thư mục dự án rồi chạy lại.');
421
- return;
793
+ let targetDir = process.cwd();
794
+ if (options.dir) {
795
+ const r = resolveTargetDir(options.dir, process.cwd());
796
+ if (!r.ok) {
797
+ printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
798
+ if (fromMenu) return;
799
+ process.exit(1);
800
+ }
801
+ targetDir = r.fullPath;
422
802
  }
423
- console.log(chalk.gray(` 📦 Package: ${chalk.white(pkg.name)}@${chalk.white(pkg.version)} • access: ${access}${dryRun ? ' • dry-run' : ''}`));
424
-
425
- if (isNpmrcTracked()) {
426
- console.log(chalk.yellowBright('\n ⚠️ Phát hiện .npmrc đang nằm trong git.'));
427
- console.log(chalk.gray(' File này thường chứa token, không nên commit/publish cùng code.'));
428
- console.log(chalk.gray(' Tool sẽ publish mà không cần commit .npmrc — hãy gỡ nó: git rm --cached .npmrc\n'));
803
+ if (!branch) branch = getCurrentBranch(targetDir);
804
+ if (!isGitRepo(targetDir)) {
805
+ printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải Git repository.`]);
806
+ if (fromMenu) return;
807
+ process.exit(1);
429
808
  }
430
-
431
- const login = checkNpmLogin();
432
- if (!login.loggedIn && !process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) {
433
- console.log(chalk.yellow(' 🔒 Chưa login npm.'));
434
- console.log(chalk.cyan(' 👉 Chạy: npm login (hoặc export NPM_TOKEN=... khi chạy CI)'));
435
- if (isInteractive) {
436
- const go = await confirm({ message: 'Đã login xong, publish tiếp?', default: false });
437
- if (!go) {
438
- console.log(chalk.gray(' Đã bỏ qua publish npm.\n'));
439
- return;
809
+ if (!branch) {
810
+ printErrorBox('KHÔNG XÁC ĐỊNH ĐƯỢC BRANCH', ['Đang ở detached HEAD nên không biết pull branch nào.'], 'Checkout về branch chính rồi chạy lại.');
811
+ if (fromMenu) return;
812
+ process.exit(1);
813
+ }
814
+ // Ở chế độ menu luôn hỏi Tiếp tục / Quay lại; ở chế độ lệnh trực tiếp cũng hỏi nếu là terminal tương tác.
815
+ const isInteractive = !options.yes;
816
+ try {
817
+ // Luôn fast-forward để tránh tự tạo merge commit ngoài ý muốn.
818
+ printSection(`PULL --ff-only ${remote}/${branch}`);
819
+ printTargetDir(targetDir);
820
+ const result = pullFromRemote(remote, branch, targetDir);
821
+ console.log(chalk.hex('#34D399')(` [OK] ${result || 'Repository đã cập nhật (fast-forward).'}\n`));
822
+ if (!fromMenu) printFooter();
823
+ } catch (error) {
824
+ printErrorBox('PULL THẤT BẠI', [String(error.message).split('\n')[0]], 'Pull --ff-only thất bại khi lịch sử bị lệch. Có thể thử pull --rebase.');
825
+ if (!isInteractive) {
826
+ if (fromMenu) return;
827
+ process.exit(1);
828
+ }
829
+ const choice = await select({
830
+ message: 'Pull fast-forward thất bại. Bạn muốn làm gì tiếp?',
831
+ choices: [
832
+ { name: '» Tiếp tục: thử pull --rebase', value: 'rebase', description: `git pull --rebase ${remote} ${branch}` },
833
+ { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn hành động' },
834
+ { name: 'x Thoát (tự xử lý tay)', value: 'exit', description: 'Không làm gì thêm' }
835
+ ]
836
+ });
837
+ if (choice === 'rebase') {
838
+ try {
839
+ console.log(chalk.hex('#38BDF8')(`\n » Đang thử: git pull --rebase ${remote} ${branch}...`));
840
+ const out = pullRebaseFromRemote(remote, branch, targetDir);
841
+ console.log(chalk.hex('#34D399')(` [OK] Pull --rebase thành công! ${String(out).split('\n')[0].slice(0, 100)}\n`));
842
+ if (!fromMenu) printFooter();
843
+ } catch (rebaseErr) {
844
+ printErrorBox('PULL --REBASE THẤT BẠI', [String(rebaseErr.message).split('\n')[0]], 'Có thể bị conflict. Chạy git status để giải quyết rồi pull lại.');
845
+ if (!fromMenu) process.exit(1);
846
+ }
847
+ } else if (choice === 'back') {
848
+ if (!fromMenu) {
849
+ await runMainMenuLoop();
440
850
  }
851
+ // Nếu đã ở trong menu thì return để caller hỏi Tiếp tục/Quay lại.
852
+ return;
441
853
  } else {
442
- console.log(chalk.gray(' Chế độ -y mà chưa login nên bỏ qua publish.\n'));
854
+ if (!fromMenu) process.exit(1);
443
855
  return;
444
856
  }
445
- } else if (login.loggedIn) {
446
- console.log(chalk.gray(` 👤 npm user: ${login.user}`));
447
857
  }
858
+ }
448
859
 
449
- spinner.start(dryRun ? 'Chạy thử npm publish --dry-run…' : `Đang publish ${pkg.name}@${pkg.version}…`);
450
- const res = publishToNpm({ access, dryRun, cwd: process.cwd() });
451
- if (res.ok) {
452
- spinner.stop();
453
- printNpmResult({ ok: true, pkgName: pkg.name, version: pkg.version, dryRun });
454
- printFooter();
455
- return;
860
+ async function runEditCommitTimeCommand(options = {}) {
861
+ const fromMenu = options.fromMenu === true;
862
+ if (!fromMenu) printBanner(VERSION);
863
+ let targetDir = process.cwd();
864
+ if (options.dir) {
865
+ const r = resolveTargetDir(options.dir, process.cwd());
866
+ if (!r.ok) {
867
+ printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
868
+ if (fromMenu) return;
869
+ process.exit(1);
870
+ }
871
+ targetDir = r.fullPath;
456
872
  }
457
-
458
- spinner.fail(chalk.red('Publish thất bại'));
459
- printNpmResult({ ok: false, pkgName: pkg.name, version: pkg.version, output: res.output, hint: res.parsed.hint, dryRun });
460
- if (res.parsed.code === 'VERSION_EXISTS' && isInteractive) {
461
- const bump = await confirm({ message: 'Tự tăng patch version (npm version patch) rồi publish lại?', default: true });
462
- if (bump) {
463
- const { execSync } = await import('child_process');
464
- try {
465
- execSync('npm version patch --no-git-tag-version', { stdio: 'ignore' });
466
- console.log(chalk.hex('#34D399')(' ✓ Đã tăng version, chạy lại tool với --publish để publish bản mới.\n'));
467
- } catch {
468
- printErrorBox('KHÔNG TĂNG ĐƯỢC VERSION', ['Kiểm tra lại package.json.']);
469
- }
873
+ if (!checkGitInstalled() || !isGitRepo(targetDir)) {
874
+ printErrorBox('CHƯA SẴN SÀNG', [`Hãy chạy lệnh trong một Git repository có Git đã cài. (Folder: ${targetDir})`]);
875
+ if (fromMenu) return;
876
+ process.exit(1);
877
+ }
878
+ printTargetDir(targetDir);
879
+ try {
880
+ const commits = getRecentCommits(100, targetDir);
881
+ if (commits.length === 0) {
882
+ printErrorBox('CHƯA CÓ COMMIT', ['Repository chưa có commit nào.']);
883
+ if (fromMenu) return;
884
+ process.exit(1);
470
885
  }
886
+ // Bước 1: chọn commit — luôn có nút « Quay lại
887
+ let commit = commits[0];
888
+ if (commits.length > 1) {
889
+ const picked = await selectWithBack({
890
+ message: `Chọn commit cần đổi thời gian (${commits.length} commit gần nhất):`,
891
+ pageSize: 12,
892
+ choices: commits.map((item) => ({
893
+ name: `» ${item.date} ${item.shortHash} ${item.subject}`,
894
+ value: item,
895
+ description: 'Sửa commit này sẽ viết lại nó + các commit phía sau'
896
+ }))
897
+ });
898
+ if (picked === BACK) return; // về menu chính (caller sẽ hỏi Tiếp tục/Quay lại)
899
+ commit = picked;
900
+ } else {
901
+ console.log(chalk.hex('#94A3B8')(` » Chỉ có 1 commit nên tự chọn: ${commit.shortHash} — ${commit.subject}\n`));
902
+ }
903
+ // Bước 2: chọn kiểu thời gian — luôn có nút « Quay lại
904
+ const dateOption = await selectWithBack({
905
+ message: 'Chọn thời gian mới: [chọn « Quay lại để đổi commit]',
906
+ choices: [
907
+ { name: '» Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
908
+ { name: '» Nhập thời gian tương đối', value: 'relative', description: 'VD: -2d, -5h, -30m' },
909
+ { name: '» Nhập ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
910
+ ]
911
+ });
912
+ if (dateOption === BACK) {
913
+ // Quay lại bước chọn commit: gọi đệ quy 1 lần để chọn lại
914
+ return await runEditCommitTimeCommand(options);
915
+ }
916
+ let dateInput = dateOption;
917
+ if (dateOption === 'relative') {
918
+ const val = await inputWithBack({
919
+ message: 'Nhập thời gian (ví dụ: -2d, -5h, -30m):',
920
+ default: '-1d',
921
+ validate: (value) => {
922
+ const parsed = parseCustomDate(value);
923
+ return parsed.valid || parsed.error;
924
+ }
925
+ });
926
+ if (val === BACK) return await runEditCommitTimeCommand(options);
927
+ dateInput = val;
928
+ } else if (dateOption === 'custom') {
929
+ const val = await inputWithBack({
930
+ message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
931
+ validate: (value) => {
932
+ const parsed = parseCustomDate(value);
933
+ return parsed.valid || parsed.error;
934
+ }
935
+ });
936
+ if (val === BACK) return await runEditCommitTimeCommand(options);
937
+ dateInput = val;
938
+ }
939
+ const parsed = parseCustomDate(dateInput);
940
+ if (!parsed.valid) {
941
+ printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsed.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
942
+ if (fromMenu) return;
943
+ process.exit(1);
944
+ }
945
+ const currentBranch = getCurrentBranch(targetDir);
946
+ const result = rewriteCommitDate(commit.hash, parsed.formatted, targetDir);
947
+ printSection('ĐÃ ĐỔI THỜI GIAN COMMIT');
948
+ console.log(` Branch: ${currentBranch}`);
949
+ console.log(` Commit: ${commit.shortHash} — ${commit.subject}`);
950
+ console.log(` Thời gian mới: ${parsed.display}`);
951
+ console.log(` Đã viết lại ${result.rewrittenCount} commit trên branch hiện tại (hash cũ đã đổi).`);
952
+ console.log(` Nhánh backup giữ bản gốc: ${result.backupBranch} (${result.backupRef})`);
953
+ console.log(chalk.hex('#FBBF24')(' [!] Nếu commit đã push, cần force push để cập nhật remote.\n'));
954
+ } catch (error) {
955
+ printErrorBox('KHÔNG THỂ ĐỔI THỜI GIAN', [error.message], 'Working tree phải sạch và commit phải nằm trên branch hiện tại.');
956
+ if (fromMenu) return;
957
+ process.exit(1);
471
958
  }
472
- printFooter();
473
959
  }
474
960
 
475
961
  const program = new Command();
@@ -483,12 +969,10 @@ program
483
969
  .option('-d, --date <datetime>', 'Thời gian commit (Ví dụ: "2024-01-15 14:30:00", "-2d", "-5h", "now")')
484
970
  .option('-m, --message <msg>', 'Thông điệp commit (Commit message)')
485
971
  .option('-b, --branch <branch>', 'Tên branch trên remote (Mặc định: main)')
972
+ .option('-C, --dir <path>', 'Folder nguồn cần push (mặc định: thư mục hiện tại; interactive sẽ cho chọn)')
486
973
  .option('-f, --force', 'Thực hiện push đè (--force)', false)
487
974
  .option('-y, --yes', 'Bỏ qua prompt tương tác, tự động dùng tham số hoặc giá trị mặc định', false)
488
975
  .option('--fix', 'Tự động gỡ file secret khỏi stage + thêm .gitignore (an toàn, không xóa file thật)', false)
489
- .option('--publish', 'Publish lên npm sau khi push Git thành công', false)
490
- .option('--access <mode>', 'Quyền publish npm (public|restricted)', 'public')
491
- .option('--dry-run', 'Chạy thử npm publish mà không publish thật', false)
492
976
  .action(async (opts) => {
493
977
  await run(opts);
494
978
  });
@@ -496,8 +980,25 @@ program
496
980
  program
497
981
  .command('fix')
498
982
  .description('Tự động quét và gỡ file secret khỏi stage, thêm vào .gitignore và làm sạch commit chưa push')
499
- .action(async () => {
500
- await runFixCommand();
983
+ .option('-C, --dir <path>', 'Folder cần fix (mặc định: thư mục hiện tại)')
984
+ .action(async (opts) => {
985
+ await runFixCommand(opts);
986
+ });
987
+
988
+ program
989
+ .command('pull [remote] [branch]')
990
+ .description('Kéo code từ remote bằng fast-forward --ff-only (mặc định: origin và branch hiện tại)')
991
+ .option('-C, --dir <path>', 'Folder repo cần pull (mặc định: thư mục hiện tại)')
992
+ .action(async (remote, branch, opts) => {
993
+ await runPullCommand(remote || 'origin', branch, opts || {});
994
+ });
995
+
996
+ program
997
+ .command('edit-time')
998
+ .description('Sửa thời gian commit (viết lại hash + descendants, tự tạo nhánh backup, yêu cầu working tree sạch)')
999
+ .option('-C, --dir <path>', 'Folder repo cần sửa (mặc định: thư mục hiện tại)')
1000
+ .action(async (opts) => {
1001
+ await runEditCommitTimeCommand(opts);
501
1002
  });
502
1003
 
503
1004
  program.parse(process.argv);