preva-cli 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +171 -2
- package/bin/preva.js +10 -0
- package/package.json +40 -3
- package/src/commands/add.js +54 -0
- package/src/commands/commit.js +138 -0
- package/src/commands/push.js +98 -0
- package/src/index.js +83 -0
- package/src/utils/colors.js +27 -0
- package/src/utils/git.js +355 -0
- package/src/utils/scanner.js +142 -0
- package/src/utils/ui.js +352 -0
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a git command safely with argument array (no shell expansion).
|
|
8
|
+
*/
|
|
9
|
+
export async function runGit(args, cwd = process.cwd()) {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
12
|
+
cwd,
|
|
13
|
+
maxBuffer: 15 * 1024 * 1024,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
return { stdout: stdout.toString(), stderr: stderr.toString() };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
const err = new Error(error.stderr ? error.stderr.toString().trim() : error.message);
|
|
19
|
+
err.code = error.code;
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if the current directory is inside a git working tree.
|
|
26
|
+
*/
|
|
27
|
+
export async function isGitRepo(cwd = process.cwd()) {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
30
|
+
return stdout.trim() === 'true';
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the repository root directory.
|
|
38
|
+
*/
|
|
39
|
+
export async function getRepoRoot(cwd = process.cwd()) {
|
|
40
|
+
const { stdout } = await runGit(['rev-parse', '--show-toplevel'], cwd);
|
|
41
|
+
return stdout.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalize Windows path backslashes to POSIX slashes.
|
|
46
|
+
*/
|
|
47
|
+
function normalizePath(p) {
|
|
48
|
+
return p.replace(/\\/g, '/');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse porcelain v1 null-delimited status output.
|
|
53
|
+
*/
|
|
54
|
+
export function parsePorcelainStatus(output) {
|
|
55
|
+
if (!output) return [];
|
|
56
|
+
|
|
57
|
+
const tokens = output.split('\0');
|
|
58
|
+
const files = [];
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
61
|
+
const token = tokens[i];
|
|
62
|
+
if (!token) continue;
|
|
63
|
+
|
|
64
|
+
const statusCode = token.slice(0, 2);
|
|
65
|
+
let filePath = token.slice(3);
|
|
66
|
+
|
|
67
|
+
// If renamed or copied, the next token is the original path
|
|
68
|
+
if (statusCode[0] === 'R' || statusCode[0] === 'C') {
|
|
69
|
+
i++; // Skip the original path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!filePath) continue;
|
|
73
|
+
|
|
74
|
+
filePath = normalizePath(filePath);
|
|
75
|
+
const stagedStatus = statusCode[0];
|
|
76
|
+
const workTreeStatus = statusCode[1];
|
|
77
|
+
|
|
78
|
+
let status = 'modified';
|
|
79
|
+
let isTracked = true;
|
|
80
|
+
|
|
81
|
+
if (statusCode === '??') {
|
|
82
|
+
status = 'new';
|
|
83
|
+
isTracked = false;
|
|
84
|
+
} else if (workTreeStatus === 'D' || stagedStatus === 'D') {
|
|
85
|
+
status = 'deleted';
|
|
86
|
+
} else if (workTreeStatus === 'A' || stagedStatus === 'A') {
|
|
87
|
+
status = 'new';
|
|
88
|
+
} else if (stagedStatus === 'R') {
|
|
89
|
+
status = 'renamed';
|
|
90
|
+
} else {
|
|
91
|
+
status = 'modified';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Only include files that have unstaged changes or are untracked
|
|
95
|
+
// (i.e. files that can be staged by git add)
|
|
96
|
+
const canBeStaged = workTreeStatus !== ' ' || statusCode === '??';
|
|
97
|
+
|
|
98
|
+
if (canBeStaged) {
|
|
99
|
+
files.push({
|
|
100
|
+
path: filePath,
|
|
101
|
+
status,
|
|
102
|
+
isTracked,
|
|
103
|
+
statusCode,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get all files available to be staged.
|
|
113
|
+
*/
|
|
114
|
+
export async function getChangedFiles(paths = [], cwd = process.cwd()) {
|
|
115
|
+
const args = ['status', '--porcelain=v1', '-z', '-uall'];
|
|
116
|
+
if (paths.length > 0) {
|
|
117
|
+
args.push('--', ...paths);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { stdout } = await runGit(args, cwd);
|
|
121
|
+
return parsePorcelainStatus(stdout);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get all files currently staged in the index.
|
|
126
|
+
*/
|
|
127
|
+
export async function getStagedFiles(cwd = process.cwd()) {
|
|
128
|
+
try {
|
|
129
|
+
const { stdout } = await runGit(['diff', '--cached', '--name-status', '-M', '-z'], cwd);
|
|
130
|
+
if (!stdout) return [];
|
|
131
|
+
const tokens = stdout.split('\0');
|
|
132
|
+
const files = [];
|
|
133
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
134
|
+
const token = tokens[i];
|
|
135
|
+
if (!token) continue;
|
|
136
|
+
const code = token[0];
|
|
137
|
+
let filePath = '';
|
|
138
|
+
if (code === 'R' || code === 'C') {
|
|
139
|
+
i++;
|
|
140
|
+
filePath = tokens[++i];
|
|
141
|
+
} else {
|
|
142
|
+
filePath = tokens[++i];
|
|
143
|
+
}
|
|
144
|
+
if (!filePath) continue;
|
|
145
|
+
filePath = normalizePath(filePath);
|
|
146
|
+
let status = 'modified';
|
|
147
|
+
if (code === 'A') status = 'new';
|
|
148
|
+
else if (code === 'D') status = 'deleted';
|
|
149
|
+
else if (code === 'R') status = 'renamed';
|
|
150
|
+
files.push({ path: filePath, status, code });
|
|
151
|
+
}
|
|
152
|
+
return files;
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Execute native git commit with message and extra arguments.
|
|
160
|
+
*/
|
|
161
|
+
export async function commitStaged(message, extraArgs = [], cwd = process.cwd()) {
|
|
162
|
+
const repoRoot = await getRepoRoot(cwd).catch(() => cwd);
|
|
163
|
+
const args = ['commit', '-m', message, ...extraArgs];
|
|
164
|
+
return runGit(args, repoRoot);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Query unpushed commits between upstream (or remote) and HEAD.
|
|
169
|
+
*/
|
|
170
|
+
export async function getUnpushedCommits(cwd = process.cwd(), remote, branch) {
|
|
171
|
+
// Check if HEAD has any commits (not unborn)
|
|
172
|
+
try {
|
|
173
|
+
await runGit(['rev-parse', '--verify', 'HEAD'], cwd);
|
|
174
|
+
} catch {
|
|
175
|
+
return { commits: [], upstreamRef: null, noCommits: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let upstreamRef = null;
|
|
179
|
+
|
|
180
|
+
if (remote && branch) {
|
|
181
|
+
const candidateRef = `${remote}/${branch}`;
|
|
182
|
+
try {
|
|
183
|
+
await runGit(['rev-parse', '--verify', candidateRef], cwd);
|
|
184
|
+
upstreamRef = candidateRef;
|
|
185
|
+
} catch {
|
|
186
|
+
upstreamRef = null;
|
|
187
|
+
}
|
|
188
|
+
} else if (remote && !branch) {
|
|
189
|
+
try {
|
|
190
|
+
const { stdout } = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
191
|
+
const currentBranch = stdout.trim();
|
|
192
|
+
if (currentBranch && currentBranch !== 'HEAD') {
|
|
193
|
+
const candidateRef = `${remote}/${currentBranch}`;
|
|
194
|
+
try {
|
|
195
|
+
await runGit(['rev-parse', '--verify', candidateRef], cwd);
|
|
196
|
+
upstreamRef = candidateRef;
|
|
197
|
+
} catch {
|
|
198
|
+
upstreamRef = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
upstreamRef = null;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// Try to get current tracking upstream
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await runGit(
|
|
208
|
+
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
|
209
|
+
cwd
|
|
210
|
+
);
|
|
211
|
+
upstreamRef = stdout.trim();
|
|
212
|
+
} catch {
|
|
213
|
+
upstreamRef = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let gitArgs = [];
|
|
218
|
+
if (upstreamRef) {
|
|
219
|
+
gitArgs = ['log', '--pretty=format:%h%x09%s%x09%an', `${upstreamRef}..HEAD`];
|
|
220
|
+
} else {
|
|
221
|
+
// Fallback: branch has no upstream or remote ref doesn't exist yet
|
|
222
|
+
try {
|
|
223
|
+
const remoteArg = remote ? [`--remotes=${remote}`] : ['--remotes'];
|
|
224
|
+
// Note: HEAD must be BEFORE --not so that HEAD is NOT negated!
|
|
225
|
+
gitArgs = ['log', '--pretty=format:%h%x09%s%x09%an', 'HEAD', '--not', ...remoteArg];
|
|
226
|
+
const testRun = await runGit(gitArgs, cwd);
|
|
227
|
+
if (!testRun.stdout.trim()) {
|
|
228
|
+
// Check if any remotes exist at all
|
|
229
|
+
const remotesList = await runGit(['remote'], cwd).catch(() => ({ stdout: '' }));
|
|
230
|
+
if (!remotesList.stdout.trim()) {
|
|
231
|
+
// No remotes exist at all: all commits on HEAD are unpushed
|
|
232
|
+
gitArgs = ['log', '--pretty=format:%h%x09%s%x09%an', 'HEAD'];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
gitArgs = ['log', '-n', '10', '--pretty=format:%h%x09%s%x09%an', 'HEAD'];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const { stdout } = await runGit(gitArgs, cwd);
|
|
242
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
243
|
+
|
|
244
|
+
const commits = lines.map((line) => {
|
|
245
|
+
const [hash, message, author] = line.split('\t');
|
|
246
|
+
return {
|
|
247
|
+
hash: hash || '',
|
|
248
|
+
message: message || '',
|
|
249
|
+
author: author || '',
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return { commits, upstreamRef };
|
|
254
|
+
} catch {
|
|
255
|
+
return { commits: [], upstreamRef: null };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Query files added, modified, or deleted across unpushed commits.
|
|
261
|
+
*/
|
|
262
|
+
export async function getUnpushedFiles(cwd = process.cwd(), upstreamRef, commits = []) {
|
|
263
|
+
let gitArgs = [];
|
|
264
|
+
|
|
265
|
+
if (upstreamRef) {
|
|
266
|
+
gitArgs = ['diff', '--name-status', '-M', '-z', `${upstreamRef}...HEAD`];
|
|
267
|
+
} else if (commits.length > 0) {
|
|
268
|
+
const oldestHash = commits[commits.length - 1].hash;
|
|
269
|
+
let hasParent = true;
|
|
270
|
+
try {
|
|
271
|
+
await runGit(['rev-parse', '--verify', `${oldestHash}^`], cwd);
|
|
272
|
+
} catch {
|
|
273
|
+
hasParent = false;
|
|
274
|
+
}
|
|
275
|
+
const base = hasParent ? `${oldestHash}^` : '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
276
|
+
gitArgs = ['diff', '--name-status', '-M', '-z', base, 'HEAD'];
|
|
277
|
+
} else {
|
|
278
|
+
try {
|
|
279
|
+
gitArgs = ['diff-tree', '--no-commit-id', '--name-status', '-M', '-z', '-r', 'HEAD'];
|
|
280
|
+
} catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const { stdout } = await runGit(gitArgs, cwd);
|
|
287
|
+
if (!stdout) return [];
|
|
288
|
+
|
|
289
|
+
const tokens = stdout.split('\0');
|
|
290
|
+
const files = [];
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
293
|
+
const token = tokens[i];
|
|
294
|
+
if (!token) continue;
|
|
295
|
+
|
|
296
|
+
const code = token[0]; // 'M', 'A', 'D', 'R', etc.
|
|
297
|
+
let filePath = '';
|
|
298
|
+
|
|
299
|
+
if (code === 'R' || code === 'C') {
|
|
300
|
+
// Token is e.g. 'R100', next token is old path, following is new path
|
|
301
|
+
i++; // skip old path
|
|
302
|
+
filePath = tokens[++i];
|
|
303
|
+
} else {
|
|
304
|
+
filePath = tokens[++i];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!filePath) continue;
|
|
308
|
+
|
|
309
|
+
filePath = normalizePath(filePath);
|
|
310
|
+
|
|
311
|
+
let status = 'modified';
|
|
312
|
+
if (code === 'A') status = 'new';
|
|
313
|
+
else if (code === 'D') status = 'deleted';
|
|
314
|
+
else if (code === 'R') status = 'renamed';
|
|
315
|
+
|
|
316
|
+
files.push({
|
|
317
|
+
status,
|
|
318
|
+
path: filePath,
|
|
319
|
+
code,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return files;
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Execute native git add for specified file paths.
|
|
331
|
+
*/
|
|
332
|
+
export async function stageFiles(files, cwd = process.cwd()) {
|
|
333
|
+
if (files.length === 0) return;
|
|
334
|
+
const repoRoot = await getRepoRoot(cwd).catch(() => cwd);
|
|
335
|
+
await runGit(['add', '--', ...files], repoRoot);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Execute native git push with passed arguments in inherited stdio.
|
|
340
|
+
*/
|
|
341
|
+
export function pushCommits(args, cwd = process.cwd()) {
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const child = spawn('git', ['push', ...args], {
|
|
344
|
+
cwd,
|
|
345
|
+
stdio: 'inherit',
|
|
346
|
+
windowsHide: true,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
child.on('error', (err) => reject(err));
|
|
350
|
+
child.on('close', (code) => {
|
|
351
|
+
if (code === 0) resolve();
|
|
352
|
+
else reject(new Error(`git push exited with code ${code}`));
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Known sensitive file regex patterns (evaluated against filename and path)
|
|
5
|
+
const SENSITIVE_PATTERNS = [
|
|
6
|
+
{ pattern: /(^|\/)\.env(\..+)?$/i, reason: 'environment variable file' },
|
|
7
|
+
{ pattern: /\.(pem|key|p12|pfx|pkcs12|keystore|jks|p8|ovpn)$/i, reason: 'private key / certificate / VPN profile' },
|
|
8
|
+
{ pattern: /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(_.*|\.pub)?$/i, reason: 'SSH key file' },
|
|
9
|
+
{ pattern: /(^|\/)(credentials|client_secret|service-?account|serviceAccountKey|google-services).*\.json$/i, reason: 'API credentials / service account' },
|
|
10
|
+
{ pattern: /(^|\/)\.(npmrc|pypirc|netrc)$/i, reason: 'package manager / auth token config' },
|
|
11
|
+
{ pattern: /(^|\/)local\.settings\.json$/i, reason: 'local secrets file' },
|
|
12
|
+
{ pattern: /(^|\/)npm-debug\.log$/i, reason: 'debug log' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Heavy directories that are usually build artifacts or dependencies
|
|
16
|
+
const HEAVY_DIR_PATTERNS = [
|
|
17
|
+
{ pattern: /(^|\/)(node_modules|dist|build|target|\.next|\.nuxt|\.venv)\//i, reason: 'heavy build directory / dependencies' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert a gitignore glob pattern to a standard regular expression.
|
|
22
|
+
*/
|
|
23
|
+
export function gitignoreToRegex(pattern) {
|
|
24
|
+
let p = pattern.trim();
|
|
25
|
+
if (!p || p.startsWith('#')) return null;
|
|
26
|
+
|
|
27
|
+
const isNegated = p.startsWith('!');
|
|
28
|
+
if (isNegated) p = p.slice(1).trim();
|
|
29
|
+
|
|
30
|
+
// Trailing slash indicates directory only
|
|
31
|
+
const isDirOnly = p.endsWith('/');
|
|
32
|
+
if (isDirOnly) p = p.slice(0, -1);
|
|
33
|
+
|
|
34
|
+
// Escape special regex characters except * and ?
|
|
35
|
+
let regexStr = p
|
|
36
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
37
|
+
.replace(/\*\*/g, '.*')
|
|
38
|
+
.replace(/\*/g, '[^/]*')
|
|
39
|
+
.replace(/\?/g, '[^/]');
|
|
40
|
+
|
|
41
|
+
if (p.startsWith('/')) {
|
|
42
|
+
regexStr = '^' + regexStr.slice(1);
|
|
43
|
+
} else {
|
|
44
|
+
regexStr = '(^|/)' + regexStr;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isDirOnly) {
|
|
48
|
+
regexStr += '(/|$)';
|
|
49
|
+
} else {
|
|
50
|
+
regexStr += '($|/)';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
return { regex: new RegExp(regexStr), isNegated };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read and parse .gitignore patterns from the given root directory.
|
|
62
|
+
*/
|
|
63
|
+
export function loadGitignorePatterns(rootPath) {
|
|
64
|
+
const gitignorePath = join(rootPath, '.gitignore');
|
|
65
|
+
if (!existsSync(gitignorePath)) return [];
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const content = readFileSync(gitignorePath, 'utf8');
|
|
69
|
+
return content
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.map(gitignoreToRegex)
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a file path matches any .gitignore pattern.
|
|
80
|
+
*/
|
|
81
|
+
export function matchesGitignore(filePath, parsedPatterns) {
|
|
82
|
+
let matched = false;
|
|
83
|
+
for (const { regex, isNegated } of parsedPatterns) {
|
|
84
|
+
if (regex.test(filePath)) {
|
|
85
|
+
matched = !isNegated;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return matched;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan a list of files and annotate with sensitivity flags and default checked state.
|
|
93
|
+
*/
|
|
94
|
+
export function scanFiles(files, cwd = process.cwd()) {
|
|
95
|
+
const gitignorePatterns = loadGitignorePatterns(cwd);
|
|
96
|
+
|
|
97
|
+
return files.map((file) => {
|
|
98
|
+
const filePath = file.path;
|
|
99
|
+
|
|
100
|
+
// 1. Check known sensitive patterns
|
|
101
|
+
for (const { pattern, reason } of SENSITIVE_PATTERNS) {
|
|
102
|
+
if (pattern.test(filePath)) {
|
|
103
|
+
return {
|
|
104
|
+
...file,
|
|
105
|
+
flagged: true,
|
|
106
|
+
reason,
|
|
107
|
+
defaultChecked: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Check heavy build directories
|
|
113
|
+
for (const { pattern, reason } of HEAVY_DIR_PATTERNS) {
|
|
114
|
+
if (pattern.test(filePath)) {
|
|
115
|
+
return {
|
|
116
|
+
...file,
|
|
117
|
+
flagged: true,
|
|
118
|
+
reason,
|
|
119
|
+
defaultChecked: false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3. Check tracked files that match .gitignore
|
|
125
|
+
if (file.isTracked && gitignorePatterns.length > 0 && matchesGitignore(filePath, gitignorePatterns)) {
|
|
126
|
+
return {
|
|
127
|
+
...file,
|
|
128
|
+
flagged: true,
|
|
129
|
+
reason: 'matches .gitignore (tracked)',
|
|
130
|
+
defaultChecked: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Safe by default
|
|
135
|
+
return {
|
|
136
|
+
...file,
|
|
137
|
+
flagged: false,
|
|
138
|
+
reason: '',
|
|
139
|
+
defaultChecked: true,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|