git-push-time 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +59 -32
- package/README.md +110 -112
- package/bin/index.js +321 -339
- package/lib/commands/edit-time.js +141 -141
- package/lib/commands/fix.js +113 -113
- package/lib/commands/github.js +429 -445
- package/lib/commands/pull.js +90 -90
- package/lib/commands/push.js +720 -720
- package/lib/config.js +220 -220
- package/lib/fix.js +286 -286
- package/lib/git.js +525 -516
- package/lib/github.js +322 -322
- package/lib/prompt-helpers.js +19 -20
- package/lib/secrets.js +92 -92
- package/lib/ui.js +1057 -1063
- package/lib/utils.js +120 -120
- package/package.json +36 -36
package/lib/fix.js
CHANGED
|
@@ -1,286 +1,286 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { findRiskyStagedFiles } from './git.js';
|
|
5
|
-
import { scanStagedContentForSecrets } from './secrets.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*/
|
|
11
|
-
const CODE_EXTENSIONS = new Set([
|
|
12
|
-
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
13
|
-
'.py', '.java', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
|
|
14
|
-
'.cs', '.php', '.rb', '.html', '.htm', '.css', '.scss', '.sass',
|
|
15
|
-
'.less', '.vue', '.svelte', '.md', '.markdown', '.txt', '.sh',
|
|
16
|
-
'.bash', '.zsh', '.bat', '.cmd', '.ps1', '.sql'
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
function isCodeOrDocFile(filePath) {
|
|
20
|
-
const base = path.basename(filePath).toLowerCase();
|
|
21
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
-
if (base === 'package.json' || base === 'package-lock.json' || base === 'tsconfig.json') return true;
|
|
23
|
-
return CODE_EXTENSIONS.has(ext);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
* 1. git rm --cached -r -f <file> (
|
|
29
|
-
* 2.
|
|
30
|
-
* 3.
|
|
31
|
-
*
|
|
32
|
-
* @param {string[]} files
|
|
33
|
-
* @param {string} cwd
|
|
34
|
-
* @returns {{ fixed: string[], alreadyClean: string[], failed: {file:string, reason:string}[], gitignoreAdded: string[], codeFilesWithSecrets: string[] }}
|
|
35
|
-
*/
|
|
36
|
-
export function autoFixStagedSecrets(files = [], cwd = process.cwd()) {
|
|
37
|
-
const unique = [...new Set((files || []).map((f) => String(f).trim()).filter(Boolean))];
|
|
38
|
-
const result = {
|
|
39
|
-
fixed: [],
|
|
40
|
-
alreadyClean: [],
|
|
41
|
-
failed: [],
|
|
42
|
-
gitignoreAdded: [],
|
|
43
|
-
codeFilesWithSecrets: []
|
|
44
|
-
};
|
|
45
|
-
if (unique.length === 0) return result;
|
|
46
|
-
|
|
47
|
-
for (const file of unique) {
|
|
48
|
-
const normalized = file.replace(/\\/g, '/');
|
|
49
|
-
let ok = false;
|
|
50
|
-
let errReason = '';
|
|
51
|
-
|
|
52
|
-
// 1.
|
|
53
|
-
const r = spawnSync('git', ['rm', '--cached', '-r', '-f', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
54
|
-
if (r.status === 0) {
|
|
55
|
-
ok = true;
|
|
56
|
-
} else {
|
|
57
|
-
const msg = String(r.stderr || r.stdout || '');
|
|
58
|
-
//
|
|
59
|
-
const rRestore = spawnSync('git', ['restore', '--staged', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
60
|
-
if (rRestore.status === 0) {
|
|
61
|
-
ok = true;
|
|
62
|
-
} else if (/did not match|pathspec/i.test(msg)) {
|
|
63
|
-
result.alreadyClean.push(file);
|
|
64
|
-
continue;
|
|
65
|
-
} else {
|
|
66
|
-
errReason = msg.trim().split('\n')[0] || 'unknown error';
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (ok) {
|
|
71
|
-
result.fixed.push(file);
|
|
72
|
-
} else {
|
|
73
|
-
result.failed.push({ file, reason: errReason || '
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 2.
|
|
78
|
-
const rules = [];
|
|
79
|
-
for (const f of unique) {
|
|
80
|
-
const normalized = f.replace(/\\/g, '/');
|
|
81
|
-
const base = path.basename(normalized);
|
|
82
|
-
|
|
83
|
-
if (isCodeOrDocFile(normalized)) {
|
|
84
|
-
//
|
|
85
|
-
result.codeFilesWithSecrets.push(f);
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (/^\.env(\..+)?$/i.test(base)) {
|
|
90
|
-
rules.push('.env*');
|
|
91
|
-
} else if (base.toLowerCase() === '.npmrc') {
|
|
92
|
-
rules.push('.npmrc');
|
|
93
|
-
} else if (/\.pem$|\.key$/i.test(base)) {
|
|
94
|
-
rules.push('*.pem\n*.key');
|
|
95
|
-
} else if (/id_rsa|id_ed25519|id_ecdsa|id_dsa/i.test(base)) {
|
|
96
|
-
rules.push('id_rsa*\nid_ed25519*\nid_ecdsa*');
|
|
97
|
-
} else if (/secrets?\.json$|credentials?\.json$|service-account.*\.json$/i.test(base)) {
|
|
98
|
-
rules.push('*secret*.json\n*credential*.json');
|
|
99
|
-
} else if (/\.pfx$|\.p12$/i.test(base)) {
|
|
100
|
-
rules.push('*.pfx\n*.p12');
|
|
101
|
-
} else {
|
|
102
|
-
rules.push(normalized);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const gitignorePath = path.join(cwd, '.gitignore');
|
|
107
|
-
let existing = '';
|
|
108
|
-
try {
|
|
109
|
-
if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf-8');
|
|
110
|
-
} catch { existing = ''; }
|
|
111
|
-
|
|
112
|
-
const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
|
113
|
-
const toAdd = [];
|
|
114
|
-
for (const chunk of rules) {
|
|
115
|
-
for (const line of String(chunk).split(/\r?\n/)) {
|
|
116
|
-
const t = line.trim();
|
|
117
|
-
if (t && !existingLines.has(t)) {
|
|
118
|
-
toAdd.push(t);
|
|
119
|
-
existingLines.add(t);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (toAdd.length > 0) {
|
|
125
|
-
try {
|
|
126
|
-
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
127
|
-
fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf-8');
|
|
128
|
-
result.gitignoreAdded = toAdd;
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
132
|
-
} catch {
|
|
133
|
-
//
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return result;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
export function hasStagedChanges(cwd = process.cwd()) {
|
|
142
|
-
try {
|
|
143
|
-
const r = spawnSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
144
|
-
if (r.status !== 0) return false;
|
|
145
|
-
return (r.stdout || '').trim().length > 0;
|
|
146
|
-
} catch {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
*
|
|
153
|
-
*/
|
|
154
|
-
export function findRiskyWorkingTreeFiles(cwd = process.cwd()) {
|
|
155
|
-
const denyNames = [
|
|
156
|
-
'.env', '.env.local', '.env.development', '.env.production',
|
|
157
|
-
'.npmrc', 'secrets.json', 'secret.json', 'credentials.json',
|
|
158
|
-
'id_rsa', 'id_ed25519'
|
|
159
|
-
];
|
|
160
|
-
const found = [];
|
|
161
|
-
for (const name of denyNames) {
|
|
162
|
-
const fullPath = path.join(cwd, name);
|
|
163
|
-
if (fs.existsSync(fullPath)) {
|
|
164
|
-
const r = spawnSync('git', ['check-ignore', '-q', name], { cwd });
|
|
165
|
-
if (r.status !== 0) {
|
|
166
|
-
found.push(name);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return found;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
*
|
|
175
|
-
*/
|
|
176
|
-
export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
|
|
177
|
-
const cleanBranch = String(branch || 'main').trim() || 'main';
|
|
178
|
-
//
|
|
179
|
-
if (/[\s~^:?*\[\\@{]/.test(cleanBranch) || cleanBranch.includes('..')) return null;
|
|
180
|
-
try {
|
|
181
|
-
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', `${cleanBranch}@{u}`], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
182
|
-
if (r.status === 0) {
|
|
183
|
-
const upstream = (r.stdout || '').trim();
|
|
184
|
-
if (upstream) return upstream;
|
|
185
|
-
}
|
|
186
|
-
} catch {}
|
|
187
|
-
|
|
188
|
-
const candidates = [`origin/${cleanBranch}`, 'origin/main', 'origin/master'];
|
|
189
|
-
for (const ref of candidates) {
|
|
190
|
-
try {
|
|
191
|
-
const r = spawnSync('git', ['rev-parse', '--verify', ref], { cwd, stdio: 'ignore' });
|
|
192
|
-
if (r.status === 0) return ref;
|
|
193
|
-
} catch {}
|
|
194
|
-
}
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
*
|
|
200
|
-
*/
|
|
201
|
-
export function getUnpushedCommits(branch = 'main', cwd = process.cwd()) {
|
|
202
|
-
const upstream = getUpstreamRef(branch, cwd);
|
|
203
|
-
if (!upstream) {
|
|
204
|
-
try {
|
|
205
|
-
const r = spawnSync('git', ['log', '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
206
|
-
if (r.status !== 0) return [];
|
|
207
|
-
return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
208
|
-
} catch {
|
|
209
|
-
return [];
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
try {
|
|
213
|
-
const r = spawnSync('git', ['log', `${upstream}..HEAD`, '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
214
|
-
if (r.status !== 0) return [];
|
|
215
|
-
return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
216
|
-
} catch {
|
|
217
|
-
return [];
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Squash
|
|
223
|
-
*
|
|
224
|
-
*/
|
|
225
|
-
export function squashUnpushedCommits(branch = 'main', commitMsg = 'Clean commit without secrets', dateFormatted = null, cwd = process.cwd()) {
|
|
226
|
-
const upstream = getUpstreamRef(branch, cwd);
|
|
227
|
-
const usedAmendForRoot = { value: false };
|
|
228
|
-
try {
|
|
229
|
-
if (upstream) {
|
|
230
|
-
const r1 = spawnSync('git', ['reset', '--soft', upstream], { cwd, encoding: 'utf-8' });
|
|
231
|
-
if (r1.status !== 0) {
|
|
232
|
-
throw new Error(String(r1.stderr || r1.stdout || '
|
|
233
|
-
}
|
|
234
|
-
} else {
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
const rRoot = spawnSync('git', ['rev-list', '--max-parents=0', 'HEAD'], { cwd, encoding: 'utf-8' });
|
|
238
|
-
if (rRoot.status !== 0) throw new Error('
|
|
239
|
-
const rootCommit = (rRoot.stdout || '').trim().split(/\s+/)[0];
|
|
240
|
-
if (!rootCommit) throw new Error('
|
|
241
|
-
const rReset = spawnSync('git', ['reset', '--soft', rootCommit], { cwd, encoding: 'utf-8' });
|
|
242
|
-
if (rReset.status !== 0) throw new Error('
|
|
243
|
-
usedAmendForRoot.value = true;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
const risky = findRiskyStagedFiles(cwd);
|
|
248
|
-
const findings = scanStagedContentForSecrets(cwd);
|
|
249
|
-
const allSecretFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
|
|
250
|
-
if (allSecretFiles.length > 0) {
|
|
251
|
-
autoFixStagedSecrets(allSecretFiles, cwd);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
256
|
-
|
|
257
|
-
if (!hasStagedChanges(cwd) && !usedAmendForRoot.value) {
|
|
258
|
-
return { ok: false, error: '
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
const env = { ...process.env };
|
|
263
|
-
if (dateFormatted) {
|
|
264
|
-
env.GIT_AUTHOR_DATE = dateFormatted;
|
|
265
|
-
env.GIT_COMMITTER_DATE = dateFormatted;
|
|
266
|
-
}
|
|
267
|
-
const safeMsg = String(commitMsg || 'Clean commit without secrets');
|
|
268
|
-
let rCommit;
|
|
269
|
-
if (usedAmendForRoot.value) {
|
|
270
|
-
// Amend root
|
|
271
|
-
rCommit = spawnSync('git', ['commit', '--amend', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
|
|
272
|
-
} else {
|
|
273
|
-
rCommit = spawnSync('git', ['commit', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
|
|
274
|
-
}
|
|
275
|
-
if (rCommit.status !== 0) {
|
|
276
|
-
const msg = String(rCommit.stderr || rCommit.stdout || '').trim();
|
|
277
|
-
if (msg.toLowerCase().includes('nothing to commit')) {
|
|
278
|
-
return { ok: false, error: '
|
|
279
|
-
}
|
|
280
|
-
throw new Error(msg || '
|
|
281
|
-
}
|
|
282
|
-
return { ok: true };
|
|
283
|
-
} catch (err) {
|
|
284
|
-
return { ok: false, error: err.message };
|
|
285
|
-
}
|
|
286
|
-
}
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { findRiskyStagedFiles } from './git.js';
|
|
5
|
+
import { scanStagedContentForSecrets } from './secrets.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Source-code and documentation file extensions.
|
|
9
|
+
* Never add these files to .gitignore automatically; that could hide project code.
|
|
10
|
+
*/
|
|
11
|
+
const CODE_EXTENSIONS = new Set([
|
|
12
|
+
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
13
|
+
'.py', '.java', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
|
|
14
|
+
'.cs', '.php', '.rb', '.html', '.htm', '.css', '.scss', '.sass',
|
|
15
|
+
'.less', '.vue', '.svelte', '.md', '.markdown', '.txt', '.sh',
|
|
16
|
+
'.bash', '.zsh', '.bat', '.cmd', '.ps1', '.sql'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function isCodeOrDocFile(filePath) {
|
|
20
|
+
const base = path.basename(filePath).toLowerCase();
|
|
21
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
+
if (base === 'package.json' || base === 'package-lock.json' || base === 'tsconfig.json') return true;
|
|
23
|
+
return CODE_EXTENSIONS.has(ext);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Safely remediate detected secrets:
|
|
28
|
+
* 1. git rm --cached -r -f <file> (remove from Git while keeping the local file)
|
|
29
|
+
* 2. Add a matching .gitignore rule (only for secret config files, never source code)
|
|
30
|
+
* 3. Stage .gitignore so the rule is ready to commit
|
|
31
|
+
*
|
|
32
|
+
* @param {string[]} files Files to unstage (e.g. ['.env', '.npmrc'])
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @returns {{ fixed: string[], alreadyClean: string[], failed: {file:string, reason:string}[], gitignoreAdded: string[], codeFilesWithSecrets: string[] }}
|
|
35
|
+
*/
|
|
36
|
+
export function autoFixStagedSecrets(files = [], cwd = process.cwd()) {
|
|
37
|
+
const unique = [...new Set((files || []).map((f) => String(f).trim()).filter(Boolean))];
|
|
38
|
+
const result = {
|
|
39
|
+
fixed: [],
|
|
40
|
+
alreadyClean: [],
|
|
41
|
+
failed: [],
|
|
42
|
+
gitignoreAdded: [],
|
|
43
|
+
codeFilesWithSecrets: []
|
|
44
|
+
};
|
|
45
|
+
if (unique.length === 0) return result;
|
|
46
|
+
|
|
47
|
+
for (const file of unique) {
|
|
48
|
+
const normalized = file.replace(/\\/g, '/');
|
|
49
|
+
let ok = false;
|
|
50
|
+
let errReason = '';
|
|
51
|
+
|
|
52
|
+
// 1. Try to unstage with git rm --cached -r -f.
|
|
53
|
+
const r = spawnSync('git', ['rm', '--cached', '-r', '-f', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
54
|
+
if (r.status === 0) {
|
|
55
|
+
ok = true;
|
|
56
|
+
} else {
|
|
57
|
+
const msg = String(r.stderr || r.stdout || '');
|
|
58
|
+
// Fall back to git restore --staged if git rm cannot match the path.
|
|
59
|
+
const rRestore = spawnSync('git', ['restore', '--staged', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
60
|
+
if (rRestore.status === 0) {
|
|
61
|
+
ok = true;
|
|
62
|
+
} else if (/did not match|pathspec/i.test(msg)) {
|
|
63
|
+
result.alreadyClean.push(file);
|
|
64
|
+
continue;
|
|
65
|
+
} else {
|
|
66
|
+
errReason = msg.trim().split('\n')[0] || 'unknown error';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ok) {
|
|
71
|
+
result.fixed.push(file);
|
|
72
|
+
} else {
|
|
73
|
+
result.failed.push({ file, reason: errReason || 'Could not unstage file' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Add .gitignore rules for secret config files (skip source files).
|
|
78
|
+
const rules = [];
|
|
79
|
+
for (const f of unique) {
|
|
80
|
+
const normalized = f.replace(/\\/g, '/');
|
|
81
|
+
const base = path.basename(normalized);
|
|
82
|
+
|
|
83
|
+
if (isCodeOrDocFile(normalized)) {
|
|
84
|
+
// Unstage source files containing secrets, but do not add them to .gitignore.
|
|
85
|
+
result.codeFilesWithSecrets.push(f);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (/^\.env(\..+)?$/i.test(base)) {
|
|
90
|
+
rules.push('.env*');
|
|
91
|
+
} else if (base.toLowerCase() === '.npmrc') {
|
|
92
|
+
rules.push('.npmrc');
|
|
93
|
+
} else if (/\.pem$|\.key$/i.test(base)) {
|
|
94
|
+
rules.push('*.pem\n*.key');
|
|
95
|
+
} else if (/id_rsa|id_ed25519|id_ecdsa|id_dsa/i.test(base)) {
|
|
96
|
+
rules.push('id_rsa*\nid_ed25519*\nid_ecdsa*');
|
|
97
|
+
} else if (/secrets?\.json$|credentials?\.json$|service-account.*\.json$/i.test(base)) {
|
|
98
|
+
rules.push('*secret*.json\n*credential*.json');
|
|
99
|
+
} else if (/\.pfx$|\.p12$/i.test(base)) {
|
|
100
|
+
rules.push('*.pfx\n*.p12');
|
|
101
|
+
} else {
|
|
102
|
+
rules.push(normalized);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
107
|
+
let existing = '';
|
|
108
|
+
try {
|
|
109
|
+
if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf-8');
|
|
110
|
+
} catch { existing = ''; }
|
|
111
|
+
|
|
112
|
+
const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
|
113
|
+
const toAdd = [];
|
|
114
|
+
for (const chunk of rules) {
|
|
115
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
116
|
+
const t = line.trim();
|
|
117
|
+
if (t && !existingLines.has(t)) {
|
|
118
|
+
toAdd.push(t);
|
|
119
|
+
existingLines.add(t);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (toAdd.length > 0) {
|
|
125
|
+
try {
|
|
126
|
+
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
127
|
+
fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf-8');
|
|
128
|
+
result.gitignoreAdded = toAdd;
|
|
129
|
+
|
|
130
|
+
// Stage .gitignore so the new rules are not missed when committing.
|
|
131
|
+
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore file-write errors.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Check whether anything remains to commit after auto-fix. */
|
|
141
|
+
export function hasStagedChanges(cwd = process.cwd()) {
|
|
142
|
+
try {
|
|
143
|
+
const r = spawnSync('git', ['diff', '--cached', '--name-only'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
144
|
+
if (r.status !== 0) return false;
|
|
145
|
+
return (r.stdout || '').trim().length > 0;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Find common secret files in the working directory that are not ignored by .gitignore.
|
|
153
|
+
*/
|
|
154
|
+
export function findRiskyWorkingTreeFiles(cwd = process.cwd()) {
|
|
155
|
+
const denyNames = [
|
|
156
|
+
'.env', '.env.local', '.env.development', '.env.production',
|
|
157
|
+
'.npmrc', 'secrets.json', 'secret.json', 'credentials.json',
|
|
158
|
+
'id_rsa', 'id_ed25519'
|
|
159
|
+
];
|
|
160
|
+
const found = [];
|
|
161
|
+
for (const name of denyNames) {
|
|
162
|
+
const fullPath = path.join(cwd, name);
|
|
163
|
+
if (fs.existsSync(fullPath)) {
|
|
164
|
+
const r = spawnSync('git', ['check-ignore', '-q', name], { cwd });
|
|
165
|
+
if (r.status !== 0) {
|
|
166
|
+
found.push(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find the upstream ref for the current branch (e.g. origin/main or origin/master).
|
|
175
|
+
*/
|
|
176
|
+
export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
|
|
177
|
+
const cleanBranch = String(branch || 'main').trim() || 'main';
|
|
178
|
+
// Reject unsafe Git ref characters before passing the value to Git.
|
|
179
|
+
if (/[\s~^:?*\[\\@{]/.test(cleanBranch) || cleanBranch.includes('..')) return null;
|
|
180
|
+
try {
|
|
181
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', `${cleanBranch}@{u}`], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
182
|
+
if (r.status === 0) {
|
|
183
|
+
const upstream = (r.stdout || '').trim();
|
|
184
|
+
if (upstream) return upstream;
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
|
|
188
|
+
const candidates = [`origin/${cleanBranch}`, 'origin/main', 'origin/master'];
|
|
189
|
+
for (const ref of candidates) {
|
|
190
|
+
try {
|
|
191
|
+
const r = spawnSync('git', ['rev-parse', '--verify', ref], { cwd, stdio: 'ignore' });
|
|
192
|
+
if (r.status === 0) return ref;
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the list of unpushed commits.
|
|
200
|
+
*/
|
|
201
|
+
export function getUnpushedCommits(branch = 'main', cwd = process.cwd()) {
|
|
202
|
+
const upstream = getUpstreamRef(branch, cwd);
|
|
203
|
+
if (!upstream) {
|
|
204
|
+
try {
|
|
205
|
+
const r = spawnSync('git', ['log', '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
206
|
+
if (r.status !== 0) return [];
|
|
207
|
+
return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const r = spawnSync('git', ['log', `${upstream}..HEAD`, '--oneline'], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
214
|
+
if (r.status !== 0) return [];
|
|
215
|
+
return (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Squash unpushed commits into one clean commit, removing secrets from
|
|
223
|
+
* intermediate commits (for example, an earlier commit containing .npmrc or .env).
|
|
224
|
+
*/
|
|
225
|
+
export function squashUnpushedCommits(branch = 'main', commitMsg = 'Clean commit without secrets', dateFormatted = null, cwd = process.cwd()) {
|
|
226
|
+
const upstream = getUpstreamRef(branch, cwd);
|
|
227
|
+
const usedAmendForRoot = { value: false };
|
|
228
|
+
try {
|
|
229
|
+
if (upstream) {
|
|
230
|
+
const r1 = spawnSync('git', ['reset', '--soft', upstream], { cwd, encoding: 'utf-8' });
|
|
231
|
+
if (r1.status !== 0) {
|
|
232
|
+
throw new Error(String(r1.stderr || r1.stdout || 'Could not reset to upstream').trim());
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// Without an upstream (a new repository), squash everything into one commit
|
|
236
|
+
// by soft-resetting to the root and amending it instead of creating a second commit.
|
|
237
|
+
const rRoot = spawnSync('git', ['rev-list', '--max-parents=0', 'HEAD'], { cwd, encoding: 'utf-8' });
|
|
238
|
+
if (rRoot.status !== 0) throw new Error('Could not read the root commit.');
|
|
239
|
+
const rootCommit = (rRoot.stdout || '').trim().split(/\s+/)[0];
|
|
240
|
+
if (!rootCommit) throw new Error('Root commit not found.');
|
|
241
|
+
const rReset = spawnSync('git', ['reset', '--soft', rootCommit], { cwd, encoding: 'utf-8' });
|
|
242
|
+
if (rReset.status !== 0) throw new Error('Could not reset to the root commit.');
|
|
243
|
+
usedAmendForRoot.value = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Scan again and remove any secret files still staged.
|
|
247
|
+
const risky = findRiskyStagedFiles(cwd);
|
|
248
|
+
const findings = scanStagedContentForSecrets(cwd);
|
|
249
|
+
const allSecretFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
|
|
250
|
+
if (allSecretFiles.length > 0) {
|
|
251
|
+
autoFixStagedSecrets(allSecretFiles, cwd);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Ensure .gitignore is staged, if it exists.
|
|
255
|
+
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
256
|
+
|
|
257
|
+
if (!hasStagedChanges(cwd) && !usedAmendForRoot.value) {
|
|
258
|
+
return { ok: false, error: 'No changes remain to squash into a new commit.' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create a new clean commit.
|
|
262
|
+
const env = { ...process.env };
|
|
263
|
+
if (dateFormatted) {
|
|
264
|
+
env.GIT_AUTHOR_DATE = dateFormatted;
|
|
265
|
+
env.GIT_COMMITTER_DATE = dateFormatted;
|
|
266
|
+
}
|
|
267
|
+
const safeMsg = String(commitMsg || 'Clean commit without secrets');
|
|
268
|
+
let rCommit;
|
|
269
|
+
if (usedAmendForRoot.value) {
|
|
270
|
+
// Amend the root to keep a single commit when there is no upstream.
|
|
271
|
+
rCommit = spawnSync('git', ['commit', '--amend', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
|
|
272
|
+
} else {
|
|
273
|
+
rCommit = spawnSync('git', ['commit', '-m', safeMsg], { cwd, env, encoding: 'utf-8' });
|
|
274
|
+
}
|
|
275
|
+
if (rCommit.status !== 0) {
|
|
276
|
+
const msg = String(rCommit.stderr || rCommit.stdout || '').trim();
|
|
277
|
+
if (msg.toLowerCase().includes('nothing to commit')) {
|
|
278
|
+
return { ok: false, error: 'Nothing to commit after cleanup (working tree is clean).' };
|
|
279
|
+
}
|
|
280
|
+
throw new Error(msg || 'Could not create the squashed commit');
|
|
281
|
+
}
|
|
282
|
+
return { ok: true };
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return { ok: false, error: err.message };
|
|
285
|
+
}
|
|
286
|
+
}
|