git-push-time 1.1.3 → 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.
- package/README.md +109 -111
- package/bin/index.js +45 -46
- package/lib/commands/edit-time.js +27 -27
- package/lib/commands/fix.js +19 -19
- package/lib/commands/github.js +98 -98
- package/lib/commands/pull.js +13 -13
- package/lib/commands/push.js +147 -147
- package/lib/config.js +17 -17
- package/lib/fix.js +34 -34
- package/lib/git.js +88 -88
- package/lib/github.js +39 -39
- package/lib/prompt-helpers.js +19 -20
- package/lib/secrets.js +12 -12
- package/lib/ui.js +122 -125
- package/lib/utils.js +15 -15
- package/package.json +1 -1
package/lib/git.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Check whether Git is installed on this machine.
|
|
7
7
|
* @returns {boolean}
|
|
8
8
|
*/
|
|
9
9
|
export function checkGitInstalled() {
|
|
@@ -16,36 +16,36 @@ export function checkGitInstalled() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Check whether the current directory is inside a Git repository.
|
|
20
20
|
* @param {string} cwd
|
|
21
21
|
* @returns {boolean}
|
|
22
22
|
*/
|
|
23
23
|
export function isGitRepo(cwd = process.cwd()) {
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// Use spawnSync with an argument array instead of a shell to avoid Windows errors
|
|
25
|
+
// when the folder contains a file named git.* (e.g. git.js) that cmd.exe may resolve via PATHEXT.
|
|
26
26
|
const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8' });
|
|
27
27
|
return r.status === 0 && (r.stdout || '').trim() === 'true';
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Initialize a Git repository in the current directory.
|
|
32
32
|
* @param {string} cwd
|
|
33
33
|
*/
|
|
34
34
|
export function initGitRepo(cwd = process.cwd()) {
|
|
35
35
|
const r = spawnSync('git', ['init'], { cwd, encoding: 'utf-8' });
|
|
36
36
|
if (r.status !== 0) {
|
|
37
|
-
throw new Error(String(r.stderr || r.stdout || 'git init
|
|
37
|
+
throw new Error(String(r.stderr || r.stdout || 'git init failed').trim());
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* Get the top-level directory of the Git repository containing `cwd`.
|
|
43
|
+
* Returns null when `cwd` is not inside a repository.
|
|
44
44
|
* @param {string} cwd
|
|
45
45
|
* @returns {string|null}
|
|
46
46
|
*/
|
|
47
47
|
export function getGitTopLevel(cwd = process.cwd()) {
|
|
48
|
-
//
|
|
48
|
+
// Use spawnSync without a shell; see the note in isGitRepo.
|
|
49
49
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' });
|
|
50
50
|
if (r.status !== 0) return null;
|
|
51
51
|
const out = (r.stdout || '').trim();
|
|
@@ -53,8 +53,8 @@ export function getGitTopLevel(cwd = process.cwd()) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* List direct child directories of `cwd` for the user to choose as a push source.
|
|
57
|
+
* Skip .git. Sort regular folders before hidden folders.
|
|
58
58
|
* @param {string} cwd
|
|
59
59
|
* @returns {{ name: string, fullPath: string }[]}
|
|
60
60
|
*/
|
|
@@ -65,7 +65,7 @@ export function listSubdirectories(cwd = process.cwd()) {
|
|
|
65
65
|
.filter((e) => {
|
|
66
66
|
if (!e.isDirectory()) return false;
|
|
67
67
|
if (e.name === '.git') return false;
|
|
68
|
-
//
|
|
68
|
+
// Skip broken symlinks.
|
|
69
69
|
try {
|
|
70
70
|
fs.statSync(path.join(cwd, e.name));
|
|
71
71
|
return true;
|
|
@@ -86,29 +86,29 @@ export function listSubdirectories(cwd = process.cwd()) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
* @param {string} inputPath
|
|
91
|
-
* @param {string} base
|
|
89
|
+
* Normalize a user-provided source folder path.
|
|
90
|
+
* @param {string} inputPath Absolute or relative path
|
|
91
|
+
* @param {string} base Base directory for resolving relative paths
|
|
92
92
|
* @returns {{ ok: boolean, fullPath?: string, error?: string }}
|
|
93
93
|
*/
|
|
94
94
|
export function resolveTargetDir(inputPath, base = process.cwd()) {
|
|
95
95
|
if (!inputPath || !String(inputPath).trim()) {
|
|
96
|
-
return { ok: false, error: '
|
|
96
|
+
return { ok: false, error: 'Path is empty.' };
|
|
97
97
|
}
|
|
98
98
|
const fullPath = path.resolve(base, String(inputPath).trim());
|
|
99
99
|
try {
|
|
100
100
|
const stat = fs.statSync(fullPath);
|
|
101
|
-
if (!stat.isDirectory()) return { ok: false, error: `'${inputPath}'
|
|
101
|
+
if (!stat.isDirectory()) return { ok: false, error: `'${inputPath}' is not a directory.` };
|
|
102
102
|
} catch {
|
|
103
|
-
return { ok: false, error: `
|
|
103
|
+
return { ok: false, error: `Directory not found: '${inputPath}'.` };
|
|
104
104
|
}
|
|
105
105
|
return { ok: true, fullPath };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* Get the current branch name, defaulting to main for display.
|
|
110
|
+
* Returns 'main' for detached HEAD to preserve legacy UI behavior.
|
|
111
|
+
* Use getCurrentBranchStrict() when detached HEAD must be detected accurately.
|
|
112
112
|
* @param {string} cwd
|
|
113
113
|
* @returns {string}
|
|
114
114
|
*/
|
|
@@ -141,19 +141,19 @@ export function getDashboardGitStatus(cwd = process.cwd()) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
*
|
|
144
|
+
* Get the current branch name, returning null for detached HEAD or errors.
|
|
145
145
|
* @param {string} cwd
|
|
146
146
|
* @returns {string|null}
|
|
147
147
|
*/
|
|
148
148
|
export function getCurrentBranchStrict(cwd = process.cwd()) {
|
|
149
|
-
//
|
|
149
|
+
// Use spawnSync without a shell; see the note in isGitRepo.
|
|
150
150
|
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
|
|
151
151
|
if (r.status !== 0) return null;
|
|
152
152
|
return (r.stdout || '').trim() || null;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* Validate
|
|
156
|
+
* Validate a branch name to prevent injection.
|
|
157
157
|
* @param {string} name
|
|
158
158
|
* @returns {boolean}
|
|
159
159
|
*/
|
|
@@ -161,8 +161,8 @@ export function isValidBranchName(name) {
|
|
|
161
161
|
if (!name || typeof name !== 'string') return false;
|
|
162
162
|
const t = name.trim();
|
|
163
163
|
if (!t || t.length > 255) return false;
|
|
164
|
-
//
|
|
165
|
-
//
|
|
164
|
+
// Reject control characters, surrounding spaces, and Git's reserved characters: ~ ^ : ? * [ \ ..
|
|
165
|
+
// For simplicity, allow letters, numbers, / - _ and . only.
|
|
166
166
|
if (/[\s~^:?*\[\\]@{]/.test(t)) return false;
|
|
167
167
|
if (t.includes('..') || t.startsWith('/') || t.endsWith('/') || t.endsWith('.lock')) return false;
|
|
168
168
|
if (t === 'HEAD') return false;
|
|
@@ -170,14 +170,14 @@ export function isValidBranchName(name) {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
*
|
|
173
|
+
* Set the primary branch name (for example, git branch -M main).
|
|
174
174
|
* @param {string} branchName
|
|
175
175
|
* @param {string} cwd
|
|
176
176
|
*/
|
|
177
177
|
export function setBranchName(branchName = 'main', cwd = process.cwd()) {
|
|
178
178
|
const clean = String(branchName || 'main').trim() || 'main';
|
|
179
179
|
if (!isValidBranchName(clean)) {
|
|
180
|
-
throw new Error(`
|
|
180
|
+
throw new Error(`Invalid branch name: '${branchName}'`);
|
|
181
181
|
}
|
|
182
182
|
const current = getCurrentBranchStrict(cwd);
|
|
183
183
|
if (current === clean) return;
|
|
@@ -188,24 +188,24 @@ export function setBranchName(branchName = 'main', cwd = process.cwd()) {
|
|
|
188
188
|
cwd, stdio: 'ignore'
|
|
189
189
|
});
|
|
190
190
|
if (existing.status === 0) {
|
|
191
|
-
throw new Error(`
|
|
191
|
+
throw new Error(`Local branch '${clean}' already exists. Choose another name or check out that branch first.`);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const r = spawnSync('git', ['branch', '-M', clean], { cwd, encoding: 'utf-8' });
|
|
195
195
|
if (r.status !== 0) {
|
|
196
|
-
const msg = String(r.stderr || r.stdout || '
|
|
196
|
+
const msg = String(r.stderr || r.stdout || 'Could not rename branch.').trim();
|
|
197
197
|
throw new Error(msg);
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
*
|
|
202
|
+
* Configure the `origin` remote for a Git repository.
|
|
203
203
|
* @param {string} repoUrl
|
|
204
204
|
* @param {string} cwd
|
|
205
205
|
*/
|
|
206
206
|
export function setGitRemote(repoUrl, cwd = process.cwd()) {
|
|
207
207
|
if (!repoUrl || typeof repoUrl !== 'string' || !repoUrl.trim()) {
|
|
208
|
-
throw new Error('URL
|
|
208
|
+
throw new Error('Repository URL is empty.');
|
|
209
209
|
}
|
|
210
210
|
const url = repoUrl.trim();
|
|
211
211
|
try {
|
|
@@ -218,26 +218,26 @@ export function setGitRemote(repoUrl, cwd = process.cwd()) {
|
|
|
218
218
|
r = spawnSync('git', ['remote', 'add', 'origin', url], { cwd, encoding: 'utf-8' });
|
|
219
219
|
}
|
|
220
220
|
if (r.status !== 0) {
|
|
221
|
-
throw new Error(String(r.stderr || r.stdout || 'git remote
|
|
221
|
+
throw new Error(String(r.stderr || r.stdout || 'git remote failed').trim());
|
|
222
222
|
}
|
|
223
223
|
} catch (error) {
|
|
224
|
-
throw new Error(`
|
|
224
|
+
throw new Error(`Could not configure Git remote URL: ${error.message}`);
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
|
-
* Stage
|
|
229
|
+
* Stage all files in the directory.
|
|
230
230
|
* @param {string} cwd
|
|
231
231
|
*/
|
|
232
232
|
export function stageAllFiles(cwd = process.cwd()) {
|
|
233
233
|
const r = spawnSync('git', ['add', '.'], { cwd, encoding: 'utf-8' });
|
|
234
234
|
if (r.status !== 0) {
|
|
235
|
-
throw new Error(String(r.stderr || r.stdout || 'git add
|
|
235
|
+
throw new Error(String(r.stderr || r.stdout || 'git add failed').trim());
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
*
|
|
240
|
+
* Check for staged or unstaged changes that have not been committed.
|
|
241
241
|
* @param {string} cwd
|
|
242
242
|
* @returns {boolean}
|
|
243
243
|
*/
|
|
@@ -252,9 +252,9 @@ export function hasChangesToCommit(cwd = process.cwd()) {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
|
-
*
|
|
255
|
+
* Create a Git commit with a custom timestamp using GIT_AUTHOR_DATE and GIT_COMMITTER_DATE.
|
|
256
256
|
* @param {string} message
|
|
257
|
-
* @param {string} dateFormatted ISO
|
|
257
|
+
* @param {string} dateFormatted ISO or Git date format
|
|
258
258
|
* @param {string} cwd
|
|
259
259
|
*/
|
|
260
260
|
export function commitWithCustomDate(message, dateFormatted, cwd = process.cwd()) {
|
|
@@ -271,15 +271,15 @@ export function commitWithCustomDate(message, dateFormatted, cwd = process.cwd()
|
|
|
271
271
|
});
|
|
272
272
|
|
|
273
273
|
if (result.status !== 0) {
|
|
274
|
-
const stderr = result.stderr || result.stdout || '
|
|
275
|
-
throw new Error(`Git commit
|
|
274
|
+
const stderr = result.stderr || result.stdout || 'Unknown error while committing';
|
|
275
|
+
throw new Error(`Git commit failed: ${stderr.trim()}`);
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
|
-
*
|
|
281
|
-
* @param {string} branch
|
|
282
|
-
* @param {boolean} force
|
|
280
|
+
* Push code to a Git remote repository.
|
|
281
|
+
* @param {string} branch Branch name (e.g. main)
|
|
282
|
+
* @param {boolean} force Whether to use --force
|
|
283
283
|
* @param {string} cwd
|
|
284
284
|
*/
|
|
285
285
|
export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()) {
|
|
@@ -295,7 +295,7 @@ export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()
|
|
|
295
295
|
});
|
|
296
296
|
|
|
297
297
|
if (result.status !== 0) {
|
|
298
|
-
const raw = (result.stderr || result.stdout || '
|
|
298
|
+
const raw = (result.stderr || result.stdout || 'Unknown error while running git push').trim();
|
|
299
299
|
const parsed = parsePushError(raw);
|
|
300
300
|
const err = new Error(raw);
|
|
301
301
|
err.code = parsed.code;
|
|
@@ -307,25 +307,25 @@ export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()
|
|
|
307
307
|
export function pullFromRemote(remote = 'origin', branch = null, cwd = process.cwd(), { rebase = false } = {}) {
|
|
308
308
|
const targetBranch = branch || getCurrentBranchStrict(cwd);
|
|
309
309
|
if (!targetBranch) {
|
|
310
|
-
throw new Error('
|
|
310
|
+
throw new Error('HEAD is detached, so the branch to pull cannot be determined.');
|
|
311
311
|
}
|
|
312
312
|
const strategy = rebase ? '--rebase' : '--ff-only';
|
|
313
313
|
const result = spawnSync('git', ['pull', strategy, remote, targetBranch], { cwd, encoding: 'utf-8', stdio: 'pipe' });
|
|
314
314
|
if (result.status !== 0) {
|
|
315
|
-
throw new Error((result.stderr || result.stdout || `Git pull ${strategy}
|
|
315
|
+
throw new Error((result.stderr || result.stdout || `Git pull ${strategy} failed`).trim());
|
|
316
316
|
}
|
|
317
317
|
return (result.stdout || '').trim();
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
export function getRecentCommits(limit = 30, cwd = process.cwd()) {
|
|
321
321
|
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
|
322
|
-
//
|
|
323
|
-
// --date=iso
|
|
322
|
+
// Pass arguments as an array to avoid shell escaping issues on Windows (%, ^, {}).
|
|
323
|
+
// --date=iso includes the time when displaying commits.
|
|
324
324
|
const result = spawnSync('git', ['log', `--max-count=${safeLimit}`, '--format=%H%x09%h%x09%ad%x09%s', '--date=iso'], {
|
|
325
325
|
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe']
|
|
326
326
|
});
|
|
327
327
|
if (result.status !== 0) {
|
|
328
|
-
//
|
|
328
|
+
// Return an empty list for repositories without commits so the caller can show a friendly error.
|
|
329
329
|
return [];
|
|
330
330
|
}
|
|
331
331
|
const output = (result.stdout || '').trim();
|
|
@@ -337,50 +337,50 @@ export function getRecentCommits(limit = 30, cwd = process.cwd()) {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
/**
|
|
340
|
-
*
|
|
341
|
-
* -
|
|
342
|
-
* -
|
|
343
|
-
* -
|
|
344
|
-
* -
|
|
345
|
-
* -
|
|
340
|
+
* Edit the author and committer dates of a commit.
|
|
341
|
+
* - Requires a clean working tree.
|
|
342
|
+
* - Requires a branch checkout (not detached HEAD).
|
|
343
|
+
* - The commit must be in the current branch history.
|
|
344
|
+
* - Rewrites the selected commit and all of its descendants.
|
|
345
|
+
* - Preserves the original history in a backup branch: backup/before-date-edit-*
|
|
346
346
|
*/
|
|
347
347
|
export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()) {
|
|
348
|
-
// 1.
|
|
348
|
+
// 1. Require a clean working tree.
|
|
349
349
|
const statusRes = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
|
|
350
|
-
if (statusRes.status !== 0) throw new Error('
|
|
351
|
-
if ((statusRes.stdout || '').trim()) throw new Error('
|
|
350
|
+
if (statusRes.status !== 0) throw new Error('Could not read working tree status.');
|
|
351
|
+
if ((statusRes.stdout || '').trim()) throw new Error('Commit or stash your changes before editing a commit timestamp.');
|
|
352
352
|
|
|
353
|
-
// 2.
|
|
353
|
+
// 2. Require a normal branch checkout.
|
|
354
354
|
const branchRes = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' });
|
|
355
355
|
const branch = (branchRes.stdout || '').trim();
|
|
356
|
-
if (!branch) throw new Error('
|
|
356
|
+
if (!branch) throw new Error('Cannot edit commits while HEAD is detached.');
|
|
357
357
|
|
|
358
358
|
const headRes = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' });
|
|
359
|
-
if (headRes.status !== 0) throw new Error('
|
|
359
|
+
if (headRes.status !== 0) throw new Error('Could not determine the current branch HEAD.');
|
|
360
360
|
const head = (headRes.stdout || '').trim();
|
|
361
361
|
|
|
362
|
-
//
|
|
362
|
+
// Pass an argument array to avoid escaping ^{commit} in Windows shells.
|
|
363
363
|
const selRes = spawnSync('git', ['rev-parse', `${commitHash}^{commit}`], { cwd, encoding: 'utf-8' });
|
|
364
|
-
if (selRes.status !== 0) throw new Error('
|
|
364
|
+
if (selRes.status !== 0) throw new Error('Commit to edit not found.');
|
|
365
365
|
const selected = (selRes.stdout || '').trim();
|
|
366
366
|
|
|
367
367
|
const isAncestor = spawnSync('git', ['merge-base', '--is-ancestor', selected, head], { cwd, stdio: 'ignore' });
|
|
368
|
-
if (isAncestor.status !== 0) throw new Error('
|
|
368
|
+
if (isAncestor.status !== 0) throw new Error('This commit is not in the current branch history.');
|
|
369
369
|
|
|
370
|
-
// 3.
|
|
370
|
+
// 3. List only commits from selected to HEAD to limit work to rewritten descendants.
|
|
371
371
|
let revRange = head;
|
|
372
372
|
const hasParent = spawnSync('git', ['rev-parse', '--verify', `${selected}^`], { cwd, stdio: 'ignore' });
|
|
373
373
|
if (hasParent.status === 0) {
|
|
374
374
|
revRange = `${selected}^..${head}`;
|
|
375
375
|
}
|
|
376
376
|
const listRes = spawnSync('git', ['rev-list', '--reverse', '--topo-order', revRange], { cwd, encoding: 'utf-8' });
|
|
377
|
-
if (listRes.status !== 0) throw new Error('
|
|
377
|
+
if (listRes.status !== 0) throw new Error('Could not read commit history.');
|
|
378
378
|
const commits = (listRes.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
379
|
-
if (!commits.includes(selected)) throw new Error('
|
|
379
|
+
if (!commits.includes(selected)) throw new Error('This commit is not in the current branch history.');
|
|
380
380
|
|
|
381
381
|
const epoch = Math.floor(new Date(dateFormatted).getTime() / 1000);
|
|
382
|
-
if (!Number.isFinite(epoch)) throw new Error('
|
|
383
|
-
//
|
|
382
|
+
if (!Number.isFinite(epoch)) throw new Error('Invalid commit timestamp.');
|
|
383
|
+
// Commit object dates use the format "<epoch> <tz>" without an @ character.
|
|
384
384
|
const gitDate = `${epoch} +0000`;
|
|
385
385
|
|
|
386
386
|
const remapped = new Map();
|
|
@@ -388,10 +388,10 @@ export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()
|
|
|
388
388
|
|
|
389
389
|
for (const hash of commits) {
|
|
390
390
|
const catRes = spawnSync('git', ['cat-file', 'commit', hash], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
391
|
-
if (catRes.status !== 0) throw new Error(`
|
|
391
|
+
if (catRes.status !== 0) throw new Error(`Could not read commit ${hash.slice(0, 7)}.`);
|
|
392
392
|
const raw = catRes.stdout || '';
|
|
393
393
|
const splitAt = raw.indexOf('\n\n');
|
|
394
|
-
if (splitAt === -1) throw new Error(`Commit ${hash.slice(0, 7)}
|
|
394
|
+
if (splitAt === -1) throw new Error(`Commit ${hash.slice(0, 7)} has an invalid format.`);
|
|
395
395
|
const headers = raw.slice(0, splitAt).split('\n');
|
|
396
396
|
const message = raw.slice(splitAt);
|
|
397
397
|
const parents = headers.filter((line) => line.startsWith('parent ')).map((line) => line.slice(7));
|
|
@@ -400,7 +400,7 @@ export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()
|
|
|
400
400
|
if (hash !== selected && !parentChanged) continue;
|
|
401
401
|
|
|
402
402
|
if (headers.some((line) => line === 'gpgsig' || line.startsWith('gpgsig '))) {
|
|
403
|
-
throw new Error('
|
|
403
|
+
throw new Error('The history contains a signed commit; editing its date would invalidate the signature.');
|
|
404
404
|
}
|
|
405
405
|
let parentIndex = 0;
|
|
406
406
|
const newHeaders = headers.map((line) => {
|
|
@@ -411,37 +411,37 @@ export function rewriteCommitDate(commitHash, dateFormatted, cwd = process.cwd()
|
|
|
411
411
|
return line;
|
|
412
412
|
});
|
|
413
413
|
if (hash === selected && !newHeaders.some((line) => line.startsWith('author ') && line.endsWith(gitDate))) {
|
|
414
|
-
throw new Error('
|
|
414
|
+
throw new Error('Could not parse commit metadata.');
|
|
415
415
|
}
|
|
416
416
|
const result = spawnSync('git', ['hash-object', '-t', 'commit', '-w', '--stdin'], {
|
|
417
417
|
cwd, input: `${newHeaders.join('\n')}${message}`, encoding: 'utf-8'
|
|
418
418
|
});
|
|
419
|
-
if (result.status !== 0) throw new Error((result.stderr || '
|
|
419
|
+
if (result.status !== 0) throw new Error((result.stderr || 'Could not create a new commit').trim());
|
|
420
420
|
remapped.set(hash, (result.stdout || '').trim());
|
|
421
421
|
changed = true;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
const newHead = remapped.get(head) || head;
|
|
425
|
-
if (!changed || newHead === head) throw new Error('
|
|
425
|
+
if (!changed || newHead === head) throw new Error('Could not create the edited commit.');
|
|
426
426
|
|
|
427
|
-
// 4.
|
|
427
|
+
// 4. Preserve the original history in a backup branch visible in `git branch`.
|
|
428
428
|
const backupBranch = `backup/before-date-edit-${Date.now()}-${head.slice(0, 7)}`;
|
|
429
429
|
const backupRef = `refs/heads/${backupBranch}`;
|
|
430
430
|
const bkRes = spawnSync('git', ['update-ref', backupRef, head], { cwd, encoding: 'utf-8' });
|
|
431
|
-
if (bkRes.status !== 0) throw new Error('
|
|
431
|
+
if (bkRes.status !== 0) throw new Error('Could not create a backup branch.');
|
|
432
432
|
const mvRes = spawnSync('git', ['update-ref', `refs/heads/${branch}`, newHead, head], { cwd, encoding: 'utf-8' });
|
|
433
433
|
if (mvRes.status !== 0) {
|
|
434
434
|
spawnSync('git', ['update-ref', '-d', backupRef], { cwd, stdio: 'ignore' });
|
|
435
|
-
throw new Error((mvRes.stderr || '
|
|
435
|
+
throw new Error((mvRes.stderr || 'Could not update the branch after rewriting history.').trim());
|
|
436
436
|
}
|
|
437
437
|
return { oldHead: head, newHead, backupRef, backupBranch, rewrittenCount: remapped.size };
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
441
|
+
* Classify `git push` failures so the CLI can show helpful guidance.
|
|
442
|
+
* Specifically handle GitHub Push Protection (GH013) secret blocks.
|
|
443
443
|
*
|
|
444
|
-
* @param {string} rawOutput stderr/stdout
|
|
444
|
+
* @param {string} rawOutput stderr/stdout from git push
|
|
445
445
|
* @returns {{ code: string, secretTypes: string[], files: string[], unblockUrl: string | null, resolveUrl: string | null }}
|
|
446
446
|
*/
|
|
447
447
|
export function parsePushError(rawOutput = '') {
|
|
@@ -458,7 +458,7 @@ export function parsePushError(rawOutput = '') {
|
|
|
458
458
|
(lower.includes('detected') && lower.includes('secret'));
|
|
459
459
|
|
|
460
460
|
if (isSecretBlocked) {
|
|
461
|
-
//
|
|
461
|
+
// Examples: "—— npm Access Token ——" or "— Generic API Key —".
|
|
462
462
|
const secretTypes = [];
|
|
463
463
|
const typeRegex = /[—\-]{2,}\s*([A-Za-z0-9 _.'()/+-]+?(?:token|key|secret|password|private key)[A-Za-z0-9 _.'()/+-]*?)\s*[—\-]{2,}/gi;
|
|
464
464
|
let m;
|
|
@@ -467,13 +467,13 @@ export function parsePushError(rawOutput = '') {
|
|
|
467
467
|
if (name && !secretTypes.includes(name)) secretTypes.push(name);
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
//
|
|
470
|
+
// Examples: "- commit abc123: .npmrc:1" or "file: .env".
|
|
471
471
|
const files = [];
|
|
472
472
|
const fileRegex = /(?:commit\s+[0-9a-f]{4,40}\s*:\s*)([^\s:]+\.[A-Za-z0-9_.-]+)/gi;
|
|
473
473
|
while ((m = fileRegex.exec(text)) !== null) {
|
|
474
474
|
if (!files.includes(m[1])) files.push(m[1]);
|
|
475
475
|
}
|
|
476
|
-
//
|
|
476
|
+
// Fallback: find suspicious filenames in the output without a greedy .* match.
|
|
477
477
|
const fallbackRegex = /(^|\s)(`?\.?(env|npmrc|id_rsa|id_ed25519|[^\s`'"]*\.pem|[^\s`'"]*\.key))\b/gi;
|
|
478
478
|
while ((m = fallbackRegex.exec(text)) !== null) {
|
|
479
479
|
const f = m[2].replace(/`/g, '');
|
|
@@ -505,10 +505,10 @@ export function parsePushError(rawOutput = '') {
|
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
508
|
+
* Quickly scan staged files before commit/push.
|
|
509
|
+
* Warn without blocking to avoid false positives.
|
|
510
510
|
* @param {string} cwd
|
|
511
|
-
* @returns {string[]}
|
|
511
|
+
* @returns {string[]} List of suspicious files
|
|
512
512
|
*/
|
|
513
513
|
export function findRiskyStagedFiles(cwd = process.cwd()) {
|
|
514
514
|
const denyList = [/^\.env(\..+)?$/i, /^\.npmrc$/i, /_rsa$/, /_ed25519$/, /\.pem$/i, /\.key$/i, /secrets?\.json$/i, /credentials?\.json$/i, /\.p12$/i, /\.pfx$/i];
|