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/lib/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync, spawnSync } from 'child_process';
2
+ import fs from 'fs';
2
3
  import path from 'path';
3
4
 
4
5
  /**
@@ -20,12 +21,10 @@ export function checkGitInstalled() {
20
21
  * @returns {boolean}
21
22
  */
22
23
  export function isGitRepo(cwd = process.cwd()) {
23
- try {
24
- const output = execSync('git rev-parse --is-inside-work-tree', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
25
- return output.trim() === 'true';
26
- } catch {
27
- return false;
28
- }
24
+ // Dùng spawnSync dạng mảng args (không qua shell) để tránh lỗi trên Windows
25
+ // khi folder chứa file tên git.* (vd: git.js) — cmd.exe sẽ nhầm `git` thành file đó do PATHEXT.
26
+ const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8' });
27
+ return r.status === 0 && (r.stdout || '').trim() === 'true';
29
28
  }
30
29
 
31
30
  /**
@@ -37,17 +36,82 @@ export function initGitRepo(cwd = process.cwd()) {
37
36
  }
38
37
 
39
38
  /**
40
- * Lấy tên branch hiện tại (hoặc mặc định là main)
41
- * @param {string} cwd
42
- * @returns {string}
39
+ * Lấy top-level của Git repo chứa `cwd` (đi ngược lên cha).
40
+ * Trả về null nếu không nằm trong repo nào.
41
+ * @param {string} cwd
42
+ * @returns {string|null}
43
43
  */
44
- export function getCurrentBranch(cwd = process.cwd()) {
44
+ export function getGitTopLevel(cwd = process.cwd()) {
45
+ // Dùng spawnSync (không qua shell) — xem ghi chú ở isGitRepo.
46
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' });
47
+ if (r.status !== 0) return null;
48
+ const out = (r.stdout || '').trim();
49
+ return out ? path.normalize(out) : null;
50
+ }
51
+
52
+ /**
53
+ * Liệt kê các thư mục con trực tiếp của `cwd` để user chọn folder nguồn push.
54
+ * Bỏ qua .git để tránh nhầm. Sắp xếp: thư mục thường trước, ẩn sau.
55
+ * @param {string} cwd
56
+ * @returns {{ name: string, fullPath: string }[]}
57
+ */
58
+ export function listSubdirectories(cwd = process.cwd()) {
59
+ try {
60
+ const entries = fs.readdirSync(cwd, { withFileTypes: true });
61
+ return entries
62
+ .filter((e) => {
63
+ if (!e.isDirectory()) return false;
64
+ if (e.name === '.git') return false;
65
+ // Bỏ qua symlink gãy
66
+ try {
67
+ fs.statSync(path.join(cwd, e.name));
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ })
73
+ .map((e) => ({ name: e.name, fullPath: path.join(cwd, e.name) }))
74
+ .sort((a, b) => {
75
+ const aHidden = a.name.startsWith('.') ? 1 : 0;
76
+ const bHidden = b.name.startsWith('.') ? 1 : 0;
77
+ if (aHidden !== bHidden) return aHidden - bHidden;
78
+ return a.name.localeCompare(b.name);
79
+ });
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Chuẩn hoá đường dẫn folder nguồn do user nhập/truyền vào.
87
+ * @param {string} inputPath đường dẫn tương đối hoặc tuyệt đối
88
+ * @param {string} base thư mục gốc để resolve đường dẫn tương đối
89
+ * @returns {{ ok: boolean, fullPath?: string, error?: string }}
90
+ */
91
+ export function resolveTargetDir(inputPath, base = process.cwd()) {
92
+ if (!inputPath || !String(inputPath).trim()) {
93
+ return { ok: false, error: 'Đường dẫn trống.' };
94
+ }
95
+ const fullPath = path.resolve(base, String(inputPath).trim());
45
96
  try {
46
- const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
47
- return branch || 'main';
97
+ const stat = fs.statSync(fullPath);
98
+ if (!stat.isDirectory()) return { ok: false, error: `'${inputPath}' không phải là thư mục.` };
48
99
  } catch {
49
- return 'main';
100
+ return { ok: false, error: `Không tìm thấy thư mục '${inputPath}'.` };
50
101
  }
102
+ return { ok: true, fullPath };
103
+ }
104
+
105
+ /**
106
+ * Lấy tên branch hiện tại (hoặc mặc định là main)
107
+ * @param {string} cwd
108
+ * @returns {string}
109
+ */
110
+ export function getCurrentBranch(cwd = process.cwd()) {
111
+ // Dùng spawnSync (không qua shell) — xem ghi chú ở isGitRepo.
112
+ const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
113
+ if (r.status !== 0) return 'main';
114
+ return (r.stdout || '').trim() || 'main';
51
115
  }
52
116
 
53
117
  /**
@@ -156,6 +220,151 @@ export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()
156
220
  }
157
221
  }
158
222
 
223
+ export function pullFromRemote(remote = 'origin', branch = getCurrentBranch(), cwd = process.cwd()) {
224
+ // Luôn dùng --ff-only để tránh tự tạo merge commit ngoài ý muốn.
225
+ const result = spawnSync('git', ['pull', '--ff-only', remote, branch], { cwd, encoding: 'utf-8', stdio: 'pipe' });
226
+ if (result.status !== 0) {
227
+ throw new Error((result.stderr || result.stdout || 'Git pull thất bại').trim());
228
+ }
229
+ return (result.stdout || '').trim();
230
+ }
231
+
232
+ /**
233
+ * Pull bằng --rebase (dùng khi --ff-only thất bại do lệch lịch sử).
234
+ * Giữ lịch sử tuyến tính, tránh merge commit ngoài ý muốn.
235
+ * @param {string} remote
236
+ * @param {string} branch
237
+ * @param {string} cwd
238
+ * @returns {string} stdout của git pull
239
+ */
240
+ export function pullRebaseFromRemote(remote = 'origin', branch = getCurrentBranch(), cwd = process.cwd()) {
241
+ const result = spawnSync('git', ['pull', '--rebase', remote, branch], { cwd, encoding: 'utf-8', stdio: 'pipe' });
242
+ if (result.status !== 0) {
243
+ throw new Error((result.stderr || result.stdout || 'Git pull --rebase thất bại').trim());
244
+ }
245
+ return (result.stdout || '').trim();
246
+ }
247
+
248
+ export function getRecentCommits(limit = 30, cwd = process.cwd()) {
249
+ const safeLimit = Math.max(1, Math.min(100, Number(limit) || 30));
250
+ // Dùng mảng args (không qua shell) để an toàn trên Windows (tránh lỗi với %, ^, {}).
251
+ // --date=iso để hiển thị cả giờ phút khi chọn commit.
252
+ const result = spawnSync('git', ['log', `--max-count=${safeLimit}`, '--format=%H%x09%h%x09%ad%x09%s', '--date=iso'], {
253
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe']
254
+ });
255
+ if (result.status !== 0) {
256
+ // Repo chưa có commit nào -> trả về rỗng để caller báo lỗi thân thiện.
257
+ return [];
258
+ }
259
+ const output = (result.stdout || '').trim();
260
+ if (!output) return [];
261
+ return output.split('\n').filter(Boolean).map((line) => {
262
+ const [hash, shortHash, date, ...subject] = line.split('\t');
263
+ return { hash, shortHash, date, subject: subject.join('\t') };
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Sửa Author Date + Committer Date của 1 commit.
269
+ * - Yêu cầu working tree sạch.
270
+ * - Chỉ cho chạy trên branch thường (không detached HEAD).
271
+ * - Commit phải nằm trên lịch sử branch hiện tại.
272
+ * - Viết lại hash của commit đó và mọi commit phía sau (descendants).
273
+ * - Giữ bản gốc bằng một nhánh backup tự động: backup/before-date-edit-*
274
+ */
275
+ export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()) {
276
+ // 1. Working tree phải sạch.
277
+ const statusRes = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
278
+ if (statusRes.status !== 0) throw new Error('Không đọc được trạng thái working tree.');
279
+ if ((statusRes.stdout || '').trim()) throw new Error('Hãy commit hoặc stash thay đổi trước khi sửa thời gian commit.');
280
+
281
+ // 2. Phải ở trên branch thường.
282
+ const branchRes = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
283
+ const branch = (branchRes.stdout || '').trim();
284
+ if (!branch) throw new Error('Không hỗ trợ sửa commit khi đang ở detached HEAD.');
285
+
286
+ const headRes = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' });
287
+ if (headRes.status !== 0) throw new Error('Không xác định được HEAD của branch hiện tại.');
288
+ const head = (headRes.stdout || '').trim();
289
+
290
+ // Dùng mảng args để tránh shell escape ^{commit} trên Windows (cmd/PowerShell).
291
+ const selRes = spawnSync('git', ['rev-parse', `${commitHash}^{commit}`], { cwd, encoding: 'utf-8' });
292
+ if (selRes.status !== 0) throw new Error('Không tìm thấy commit cần sửa.');
293
+ const selected = (selRes.stdout || '').trim();
294
+
295
+ const isAncestor = spawnSync('git', ['merge-base', '--is-ancestor', selected, head], { cwd, stdio: 'ignore' });
296
+ if (isAncestor.status !== 0) throw new Error('Commit này không nằm trên lịch sử của branch hiện tại.');
297
+
298
+ // 3. Chỉ liệt kê commit từ selected tới HEAD (đỡ nặng, đúng semantics viết lại descendants).
299
+ let revRange = head;
300
+ const hasParent = spawnSync('git', ['rev-parse', '--verify', `${selected}^`], { cwd, stdio: 'ignore' });
301
+ if (hasParent.status === 0) {
302
+ revRange = `${selected}^..${head}`;
303
+ }
304
+ const listRes = spawnSync('git', ['rev-list', '--reverse', '--topo-order', revRange], { cwd, encoding: 'utf-8' });
305
+ if (listRes.status !== 0) throw new Error('Không đọc được lịch sử commit.');
306
+ const commits = (listRes.stdout || '').trim().split('\n').filter(Boolean);
307
+ if (!commits.includes(selected)) throw new Error('Commit này không nằm trên lịch sử của branch hiện tại.');
308
+
309
+ const epoch = Math.floor(new Date(dateFormatted).getTime() / 1000);
310
+ if (!Number.isFinite(epoch)) throw new Error('Thời gian commit không hợp lệ.');
311
+ // Format trong object commit là "<epoch> <tz>", KHÔNG có ký tự @.
312
+ const gitDate = `${epoch} +0000`;
313
+
314
+ const remapped = new Map();
315
+ let changed = false;
316
+
317
+ for (const hash of commits) {
318
+ const catRes = spawnSync('git', ['cat-file', 'commit', hash], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
319
+ if (catRes.status !== 0) throw new Error(`Không đọc được commit ${hash.slice(0, 7)}.`);
320
+ const raw = catRes.stdout || '';
321
+ const splitAt = raw.indexOf('\n\n');
322
+ if (splitAt === -1) throw new Error(`Commit ${hash.slice(0, 7)} có định dạng không hợp lệ.`);
323
+ const headers = raw.slice(0, splitAt).split('\n');
324
+ const message = raw.slice(splitAt);
325
+ const parents = headers.filter((line) => line.startsWith('parent ')).map((line) => line.slice(7));
326
+ const mappedParents = parents.map((parent) => remapped.get(parent) || parent);
327
+ const parentChanged = mappedParents.some((parent, i) => parent !== parents[i]);
328
+ if (hash !== selected && !parentChanged) continue;
329
+
330
+ if (headers.some((line) => line === 'gpgsig' || line.startsWith('gpgsig '))) {
331
+ throw new Error('Lịch sử có commit đã ký số; sửa ngày sẽ làm chữ ký mất hiệu lực.');
332
+ }
333
+ let parentIndex = 0;
334
+ const newHeaders = headers.map((line) => {
335
+ if (line.startsWith('parent ')) return `parent ${mappedParents[parentIndex++]}`;
336
+ if (hash === selected && (line.startsWith('author ') || line.startsWith('committer '))) {
337
+ return line.replace(/\s-?\d+\s[+-]\d{4}$/, ` ${gitDate}`);
338
+ }
339
+ return line;
340
+ });
341
+ if (hash === selected && !newHeaders.some((line) => line.startsWith('author ') && line.endsWith(gitDate))) {
342
+ throw new Error('Không thể phân tích metadata của commit.');
343
+ }
344
+ const result = spawnSync('git', ['hash-object', '-t', 'commit', '-w', '--stdin'], {
345
+ cwd, input: `${newHeaders.join('\n')}${message}`, encoding: 'utf-8'
346
+ });
347
+ if (result.status !== 0) throw new Error((result.stderr || 'Không thể tạo commit mới').trim());
348
+ remapped.set(hash, (result.stdout || '').trim());
349
+ changed = true;
350
+ }
351
+
352
+ const newHead = remapped.get(head) || head;
353
+ if (!changed || newHead === head) throw new Error('Không tạo được commit đã sửa.');
354
+
355
+ // 4. Giữ bản gốc bằng nhánh backup thật để user thấy trong `git branch`.
356
+ const backupBranch = `backup/before-date-edit-${Date.now()}-${head.slice(0, 7)}`;
357
+ const backupRef = `refs/heads/${backupBranch}`;
358
+ const bkRes = spawnSync('git', ['update-ref', backupRef, head], { cwd, encoding: 'utf-8' });
359
+ if (bkRes.status !== 0) throw new Error('Không tạo được nhánh backup.');
360
+ const mvRes = spawnSync('git', ['update-ref', `refs/heads/${branch}`, newHead, head], { cwd, encoding: 'utf-8' });
361
+ if (mvRes.status !== 0) {
362
+ spawnSync('git', ['update-ref', '-d', backupRef], { cwd, stdio: 'ignore' });
363
+ throw new Error((mvRes.stderr || 'Không thể cập nhật branch sau khi viết lại.').trim());
364
+ }
365
+ return { oldHead: head, newHead, backupRef, backupBranch, rewrittenCount: remapped.size };
366
+ }
367
+
159
368
  /**
160
369
  * Phân loại lỗi khi `git push` thất bại để CLI hiển thị hướng dẫn thân thiện.
161
370
  * Đặc biệt xử lý GitHub Push Protection (GH013) khi push dính secret.