wendkeep 0.58.3 → 0.60.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/CHANGELOG.md +93 -0
- package/README.en.md +45 -3
- package/README.md +45 -3
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +9 -3
- package/docs/en/commands/getting-started.md +7 -3
- package/docs/en/commands/memory.md +20 -2
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/sessions-and-import.md +8 -4
- package/docs/en/commands/verify.md +12 -6
- package/docs/pt-BR/commands/changes-and-verification.md +9 -4
- package/docs/pt-BR/commands/getting-started.md +7 -3
- package/docs/pt-BR/commands/memory.md +18 -2
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/sessions-and-import.md +7 -3
- package/docs/pt-BR/commands/verify.md +11 -5
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +39 -55
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +6 -4
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +10 -5
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +65 -19
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +183 -37
- package/hooks/vault-path-safety.mjs +2 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +10 -3
- package/packages/cli/package.json +5 -0
- package/packages/harness/package.json +5 -0
- package/packages/integrations/package.json +5 -0
- package/packages/mcp/package.json +5 -0
- package/packages/pi/package.json +5 -0
- package/packages/vault/package.json +6 -0
- package/packages/vault/src/index.mjs +2 -0
- package/packages/vault/src/project-vault.mjs +327 -0
- package/packages/vault/src/vault-path-safety.mjs +558 -0
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +2 -221
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +8 -0
- package/src/validate-memory.mjs +21 -8
- package/src/verify.mjs +12 -2
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const posix = (value) => String(value || '').replaceAll('\\', '/');
|
|
7
|
+
|
|
8
|
+
function git(cwd, args, { spawn = spawnSync, binary = false } = {}) {
|
|
9
|
+
const result = spawn('git', args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: binary ? null : 'utf8',
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
});
|
|
14
|
+
if (result.error || result.status !== 0) {
|
|
15
|
+
const detail = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : result.stderr;
|
|
16
|
+
const error = new Error(`git ${args.join(' ')}: ${String(detail || result.error?.message || 'falhou').trim()}`);
|
|
17
|
+
error.code = 'FLOW_GIT_ERROR';
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
return result.stdout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gitOptional(cwd, args, { spawn = spawnSync } = {}) {
|
|
24
|
+
const result = spawn('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
25
|
+
if (result.error || ![0, 1].includes(result.status)) {
|
|
26
|
+
const detail = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : result.stderr;
|
|
27
|
+
const error = new Error(`git ${args.join(' ')}: ${String(detail || result.error?.message || 'falhou').trim()}`);
|
|
28
|
+
error.code = 'FLOW_GIT_ERROR';
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
return result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fingerprintFsEntry(path, unsafePaths = [], label = '') {
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = lstatSync(path);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === 'ENOENT') return 'missing';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isSymbolicLink()) {
|
|
43
|
+
unsafePaths.push(label || posix(path));
|
|
44
|
+
return `unsafe-link:${readlinkSync(path)}`;
|
|
45
|
+
}
|
|
46
|
+
if (stat.isFile()) {
|
|
47
|
+
if (stat.nlink > 1) unsafePaths.push(label || posix(path));
|
|
48
|
+
return `file:${stat.mode}:${stat.nlink}:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
|
|
49
|
+
}
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
const entries = readdirSync(path).sort().map((name) => [
|
|
52
|
+
name,
|
|
53
|
+
fingerprintFsEntry(join(path, name), unsafePaths, label ? `${label}/${name}` : name),
|
|
54
|
+
]);
|
|
55
|
+
return `dir:${createHash('sha256').update(JSON.stringify(entries)).digest('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
return `other:${stat.mode}:${stat.size}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveGitPath(root, value) {
|
|
61
|
+
const raw = String(value || '').trim();
|
|
62
|
+
if (!raw) return '';
|
|
63
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(root, raw);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fingerprintGitIndirection(path, unsafePaths) {
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = lstatSync(path);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code === 'ENOENT') return 'missing';
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (stat.isSymbolicLink()) {
|
|
75
|
+
unsafePaths.push('git-indirection');
|
|
76
|
+
return `unsafe-link:${readlinkSync(path)}`;
|
|
77
|
+
}
|
|
78
|
+
if (stat.isFile()) {
|
|
79
|
+
if (stat.nlink > 1) unsafePaths.push('git-indirection');
|
|
80
|
+
return `file:${stat.nlink}:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
|
|
81
|
+
}
|
|
82
|
+
return stat.isDirectory() ? 'directory' : `other:${stat.mode}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function gitMetadataSnapshot(root, options = {}) {
|
|
86
|
+
const gitDir = resolveGitPath(root, git(root, ['rev-parse', '--git-dir'], options));
|
|
87
|
+
const commonDir = resolveGitPath(root, git(root, ['rev-parse', '--git-common-dir'], options));
|
|
88
|
+
const configuredHooks = gitOptional(root, ['config', '--path', '--get', 'core.hooksPath'], options);
|
|
89
|
+
const configuredExcludes = gitOptional(root, ['config', '--path', '--get', 'core.excludesFile'], options);
|
|
90
|
+
const hooksPath = configuredHooks ? resolveGitPath(root, configuredHooks) : join(commonDir, 'hooks');
|
|
91
|
+
const unsafePaths = [];
|
|
92
|
+
const targets = [
|
|
93
|
+
['common-config', join(commonDir, 'config')],
|
|
94
|
+
['worktree-config', join(gitDir, 'config.worktree')],
|
|
95
|
+
['info-exclude', join(commonDir, 'info', 'exclude')],
|
|
96
|
+
['hooks', hooksPath],
|
|
97
|
+
...(configuredExcludes ? [['configured-excludes', resolveGitPath(root, configuredExcludes)]] : []),
|
|
98
|
+
];
|
|
99
|
+
const entries = targets.map(([label, path]) => [
|
|
100
|
+
label,
|
|
101
|
+
canonicalFsPath(path),
|
|
102
|
+
fingerprintFsEntry(path, unsafePaths, label),
|
|
103
|
+
]);
|
|
104
|
+
entries.push([
|
|
105
|
+
'git-indirection',
|
|
106
|
+
canonicalFsPath(join(root, '.git')),
|
|
107
|
+
fingerprintGitIndirection(join(root, '.git'), unsafePaths),
|
|
108
|
+
]);
|
|
109
|
+
const effectiveConfig = git(root, ['config', '--list', '--show-origin', '--show-scope', '-z'], {
|
|
110
|
+
...options,
|
|
111
|
+
binary: true,
|
|
112
|
+
});
|
|
113
|
+
entries.push(['effective-config', createHash('sha256').update(Buffer.from(effectiveConfig)).digest('hex')]);
|
|
114
|
+
return {
|
|
115
|
+
fingerprint: createHash('sha256').update(JSON.stringify(entries)).digest('hex'),
|
|
116
|
+
unsafePaths: [...new Set(unsafePaths)].sort(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hiddenIndexPaths(root, options = {}) {
|
|
121
|
+
const output = git(root, ['ls-files', '-v', '-z'], { ...options, binary: true });
|
|
122
|
+
return Buffer.from(output).toString('utf8').split('\0').filter(Boolean)
|
|
123
|
+
.filter((record) => {
|
|
124
|
+
const tag = record[0] || '';
|
|
125
|
+
return tag === 'S' || (/[a-z]/.test(tag) && tag === tag.toLowerCase());
|
|
126
|
+
})
|
|
127
|
+
.map((record) => posix(record.slice(2)))
|
|
128
|
+
.sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function trackedGitlinks(root, options = {}) {
|
|
132
|
+
const output = git(root, ['ls-files', '--stage', '-z'], { ...options, binary: true });
|
|
133
|
+
return Buffer.from(output).toString('utf8').split('\0').filter(Boolean)
|
|
134
|
+
.flatMap((record) => {
|
|
135
|
+
const tab = record.indexOf('\t');
|
|
136
|
+
if (tab < 0 || !record.startsWith('160000 ')) return [];
|
|
137
|
+
return [posix(record.slice(tab + 1))];
|
|
138
|
+
})
|
|
139
|
+
.sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function nestedSnapshotOptions(options, relPath) {
|
|
143
|
+
const prefix = posix(relPath).replace(/\/$/, '');
|
|
144
|
+
const originalSpecs = Array.isArray(options.ignoredPathspecs) ? options.ignoredPathspecs : [];
|
|
145
|
+
const rebased = originalSpecs.flatMap((spec) => {
|
|
146
|
+
const raw = String(spec || '');
|
|
147
|
+
const close = raw.startsWith(':(') ? raw.indexOf(')') : -1;
|
|
148
|
+
const magic = close >= 0 ? raw.slice(0, close + 1) : '';
|
|
149
|
+
const body = close >= 0 ? raw.slice(close + 1) : raw;
|
|
150
|
+
const comparable = process.platform === 'win32' ? body.toLowerCase() : body;
|
|
151
|
+
const comparablePrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix;
|
|
152
|
+
if (!comparable.startsWith(`${comparablePrefix}/`)) return [];
|
|
153
|
+
return [`${magic}${body.slice(prefix.length + 1)}`];
|
|
154
|
+
});
|
|
155
|
+
const parentFilter = typeof options.ignoredPathFilter === 'function' ? options.ignoredPathFilter : null;
|
|
156
|
+
return {
|
|
157
|
+
...options,
|
|
158
|
+
ignoredPathspecs: [...new Set([...originalSpecs, ...rebased])],
|
|
159
|
+
ignoredPathFilter: parentFilter ? (path) => parentFilter(`${prefix}/${posix(path)}`) : undefined,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function splitFixed(record, fields) {
|
|
164
|
+
const out = [];
|
|
165
|
+
let rest = record;
|
|
166
|
+
for (let index = 0; index < fields; index += 1) {
|
|
167
|
+
const at = rest.indexOf(' ');
|
|
168
|
+
if (at < 0) return { fields: [...out, rest], rest: '' };
|
|
169
|
+
out.push(rest.slice(0, at));
|
|
170
|
+
rest = rest.slice(at + 1);
|
|
171
|
+
}
|
|
172
|
+
return { fields: out, rest };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseStatus(output) {
|
|
176
|
+
const tokens = String(output || '').split('\0');
|
|
177
|
+
const records = [];
|
|
178
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
179
|
+
const raw = tokens[index];
|
|
180
|
+
if (!raw) continue;
|
|
181
|
+
const kind = raw[0];
|
|
182
|
+
if (kind === '1') {
|
|
183
|
+
const parsed = splitFixed(raw, 8);
|
|
184
|
+
records.push({ raw, paths: [parsed.rest] });
|
|
185
|
+
} else if (kind === '2') {
|
|
186
|
+
const parsed = splitFixed(raw, 9);
|
|
187
|
+
const original = tokens[++index] || '';
|
|
188
|
+
records.push({ raw: `${raw}\0${original}`, paths: [parsed.rest, original].filter(Boolean) });
|
|
189
|
+
} else if (kind === 'u') {
|
|
190
|
+
const parsed = splitFixed(raw, 10);
|
|
191
|
+
records.push({ raw, paths: [parsed.rest] });
|
|
192
|
+
} else if (kind === '?' || kind === '!') {
|
|
193
|
+
records.push({ raw, paths: [raw.slice(2)] });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return records;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function canonicalFsPath(value) {
|
|
200
|
+
const path = resolve(value).replaceAll('\\', '/');
|
|
201
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function topologyError(message) {
|
|
205
|
+
const error = new Error(message);
|
|
206
|
+
error.code = 'FLOW_PATH_TOPOLOGY';
|
|
207
|
+
return error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function physicalScanError(message, code = 'FLOW_PHYSICAL_SCAN_ERROR') {
|
|
211
|
+
const error = new Error(message);
|
|
212
|
+
error.code = code;
|
|
213
|
+
return error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function positiveScanLimit(value, fallback, label) {
|
|
217
|
+
const selected = value === undefined ? fallback : Number(value);
|
|
218
|
+
if (!Number.isSafeInteger(selected) || selected < 1) {
|
|
219
|
+
throw new TypeError(`${label} do scan físico deve ser inteiro positivo`);
|
|
220
|
+
}
|
|
221
|
+
return selected;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizedPhysicalScanPath(value, label) {
|
|
225
|
+
const normalized = posix(value).replace(/^\.\//, '').replace(/\/$/, '');
|
|
226
|
+
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
227
|
+
throw new TypeError(`${label} inválido no scan físico: ${value}`);
|
|
228
|
+
}
|
|
229
|
+
return normalized;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Walk a project tree without following symbolic links or Windows junctions. Only
|
|
234
|
+
* protected candidates (and every descendant of a protected directory) are hashed;
|
|
235
|
+
* the remaining tree is visited solely to discover such candidates. Exclusions are
|
|
236
|
+
* checked before lstat/readdir and therefore do not consume the entry budget.
|
|
237
|
+
*/
|
|
238
|
+
export function capturePhysicalTreeSnapshot(projectRoot, options = {}) {
|
|
239
|
+
const root = resolve(projectRoot);
|
|
240
|
+
const rootStat = lstatSync(root);
|
|
241
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
242
|
+
throw physicalScanError(`raiz do scan físico não é diretório local: ${root}`);
|
|
243
|
+
}
|
|
244
|
+
const physicalRoot = realpathSync.native(root);
|
|
245
|
+
const maxDepth = positiveScanLimit(options.maxDepth, 64, 'maxDepth');
|
|
246
|
+
const maxEntries = positiveScanLimit(options.maxEntries, 100_000, 'maxEntries');
|
|
247
|
+
const classify = typeof options.isProtectedPath === 'function' ? options.isProtectedPath : () => false;
|
|
248
|
+
const pathPrefix = String(options.pathPrefix || '').trim()
|
|
249
|
+
? normalizedPhysicalScanPath(options.pathPrefix, 'pathPrefix')
|
|
250
|
+
: '';
|
|
251
|
+
const normalizeCase = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
252
|
+
const excludedNames = new Set((options.excludedDirectoryNames || [])
|
|
253
|
+
.map((value) => normalizeCase(String(value || '').trim()))
|
|
254
|
+
.filter(Boolean));
|
|
255
|
+
const excludedPaths = [...new Set((options.excludedPaths || [])
|
|
256
|
+
.map((value) => normalizedPhysicalScanPath(value, 'excludedPath')))]
|
|
257
|
+
.map(normalizeCase)
|
|
258
|
+
.sort();
|
|
259
|
+
const fingerprints = {};
|
|
260
|
+
const unsafePaths = [];
|
|
261
|
+
let entriesScanned = 0;
|
|
262
|
+
let maxDepthSeen = 0;
|
|
263
|
+
|
|
264
|
+
const excluded = (relPath, name) => {
|
|
265
|
+
if (excludedNames.has(normalizeCase(name))) return true;
|
|
266
|
+
const candidate = normalizeCase(relPath);
|
|
267
|
+
return excludedPaths.some((path) => candidate === path || candidate.startsWith(`${path}/`));
|
|
268
|
+
};
|
|
269
|
+
const gitRelative = (projectRelative) => pathPrefix ? `${pathPrefix}/${projectRelative}` : projectRelative;
|
|
270
|
+
const failRace = (relPath, error) => {
|
|
271
|
+
throw physicalScanError(
|
|
272
|
+
`scan físico protegido ficou instável em ${gitRelative(relPath)}: ${error?.message || error}`,
|
|
273
|
+
'FLOW_PHYSICAL_SCAN_RACE',
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
const record = (path, descriptor) => {
|
|
277
|
+
fingerprints[path] = createHash('sha256').update(descriptor).digest('hex');
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const walk = (directory, directoryRel, depth, inheritedProtected) => {
|
|
281
|
+
let names;
|
|
282
|
+
try {
|
|
283
|
+
names = readdirSync(directory).sort();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
failRace(directoryRel || '.', error);
|
|
286
|
+
}
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const childRel = directoryRel ? `${directoryRel}/${name}` : name;
|
|
289
|
+
if (excluded(childRel, name)) continue;
|
|
290
|
+
const childDepth = depth + 1;
|
|
291
|
+
if (childDepth > maxDepth) {
|
|
292
|
+
throw physicalScanError(
|
|
293
|
+
`scan físico protegido excedeu profundidade máxima ${maxDepth} em ${gitRelative(childRel)}`,
|
|
294
|
+
'FLOW_PHYSICAL_SCAN_LIMIT',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
entriesScanned += 1;
|
|
298
|
+
if (entriesScanned > maxEntries) {
|
|
299
|
+
throw physicalScanError(
|
|
300
|
+
`scan físico protegido excedeu limite de entradas ${maxEntries} em ${gitRelative(childRel)}`,
|
|
301
|
+
'FLOW_PHYSICAL_SCAN_LIMIT',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
maxDepthSeen = Math.max(maxDepthSeen, childDepth);
|
|
305
|
+
const absolute = join(directory, name);
|
|
306
|
+
const protectedPath = gitRelative(childRel);
|
|
307
|
+
const isProtected = inheritedProtected || Boolean(classify(protectedPath));
|
|
308
|
+
let stat;
|
|
309
|
+
try {
|
|
310
|
+
stat = lstatSync(absolute);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
failRace(childRel, error);
|
|
313
|
+
}
|
|
314
|
+
if (stat.isSymbolicLink()) {
|
|
315
|
+
if (isProtected) {
|
|
316
|
+
let target;
|
|
317
|
+
try { target = readlinkSync(absolute); }
|
|
318
|
+
catch (error) { failRace(childRel, error); }
|
|
319
|
+
record(protectedPath, `link:${target}`);
|
|
320
|
+
unsafePaths.push(`${protectedPath} (link simbólico/junction/reparse)`);
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// lstat is authoritative for ordinary symlinks/junctions. The physical
|
|
325
|
+
// identity comparison also catches Windows reparse aliases that Node may
|
|
326
|
+
// expose as a directory. Resolving identity is bounded to this entry; a
|
|
327
|
+
// redirected directory is never opened or traversed.
|
|
328
|
+
let physical;
|
|
329
|
+
try { physical = realpathSync.native(absolute); }
|
|
330
|
+
catch (error) { failRace(childRel, error); }
|
|
331
|
+
const expectedPhysical = join(physicalRoot, ...childRel.split('/'));
|
|
332
|
+
if (canonicalFsPath(physical) !== canonicalFsPath(expectedPhysical)) {
|
|
333
|
+
if (isProtected) {
|
|
334
|
+
record(protectedPath, `reparse:${canonicalFsPath(physical)}`);
|
|
335
|
+
unsafePaths.push(`${protectedPath} (junction/reparse redirecionado)`);
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (stat.isFile()) {
|
|
340
|
+
if (isProtected) {
|
|
341
|
+
let content;
|
|
342
|
+
try { content = readFileSync(absolute); }
|
|
343
|
+
catch (error) { failRace(childRel, error); }
|
|
344
|
+
record(protectedPath, `file:${stat.mode}:${stat.size}:${stat.nlink}:${createHash('sha256').update(content).digest('hex')}`);
|
|
345
|
+
if (stat.nlink > 1) unsafePaths.push(`${protectedPath} (hardlink nlink=${stat.nlink})`);
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (stat.isDirectory()) {
|
|
350
|
+
if (isProtected) record(protectedPath, `dir:${stat.mode}`);
|
|
351
|
+
walk(absolute, childRel, childDepth, isProtected);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (isProtected) {
|
|
355
|
+
record(protectedPath, `other:${stat.mode}:${stat.size}`);
|
|
356
|
+
unsafePaths.push(`${protectedPath} (tipo físico especial)`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
walk(root, '', 0, false);
|
|
362
|
+
const orderedFingerprints = Object.fromEntries(Object.entries(fingerprints)
|
|
363
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0));
|
|
364
|
+
return {
|
|
365
|
+
schema_version: 1,
|
|
366
|
+
fingerprint: createHash('sha256').update(JSON.stringify(orderedFingerprints)).digest('hex'),
|
|
367
|
+
fingerprints: orderedFingerprints,
|
|
368
|
+
unsafe_paths: [...new Set(unsafePaths)].sort(),
|
|
369
|
+
entries_scanned: entriesScanned,
|
|
370
|
+
max_depth_seen: maxDepthSeen,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function assertRelativePathTopology(root, relPath) {
|
|
375
|
+
const normalized = posix(relPath).replace(/\/\*\*$/, '');
|
|
376
|
+
if (!normalized || isAbsolute(normalized)) {
|
|
377
|
+
throw topologyError(`path FLOW inválido para inspeção física: ${relPath}`);
|
|
378
|
+
}
|
|
379
|
+
const absolute = resolve(root, ...normalized.split('/'));
|
|
380
|
+
const fromRoot = relative(root, absolute);
|
|
381
|
+
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
382
|
+
throw topologyError(`path FLOW sai da raiz física: ${relPath}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let cursor = root;
|
|
386
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
387
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
388
|
+
cursor = join(cursor, segments[index]);
|
|
389
|
+
let stat;
|
|
390
|
+
try {
|
|
391
|
+
stat = lstatSync(cursor);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error?.code === 'ENOENT') break;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
if (stat.isSymbolicLink()) {
|
|
397
|
+
throw topologyError(`path FLOW atravessa link simbólico/reparse: ${relPath}`);
|
|
398
|
+
}
|
|
399
|
+
const physical = realpathSync.native(cursor);
|
|
400
|
+
const physicalFromRoot = relative(root, physical);
|
|
401
|
+
const escaped = physicalFromRoot === '..'
|
|
402
|
+
|| physicalFromRoot.startsWith(`..${sep}`)
|
|
403
|
+
|| isAbsolute(physicalFromRoot);
|
|
404
|
+
const redirected = process.platform === 'win32'
|
|
405
|
+
&& canonicalFsPath(physical) !== canonicalFsPath(cursor);
|
|
406
|
+
if (escaped || redirected) {
|
|
407
|
+
throw topologyError(`path FLOW sai da raiz física por reparse: ${relPath}`);
|
|
408
|
+
}
|
|
409
|
+
if (index === segments.length - 1 && stat.isFile() && stat.nlink > 1) {
|
|
410
|
+
throw topologyError(`path FLOW alterado possui hardlink: ${relPath}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function assertAllowedTreeTopology(root, allowed) {
|
|
416
|
+
if (!posix(allowed).endsWith('/**')) return;
|
|
417
|
+
const baseRel = posix(allowed).slice(0, -3);
|
|
418
|
+
const base = join(root, ...baseRel.split('/'));
|
|
419
|
+
let baseStat;
|
|
420
|
+
try {
|
|
421
|
+
baseStat = lstatSync(base);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (error?.code === 'ENOENT') return;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
if (!baseStat.isDirectory()) return;
|
|
427
|
+
const pending = [baseRel];
|
|
428
|
+
while (pending.length) {
|
|
429
|
+
const parentRel = pending.pop();
|
|
430
|
+
const parent = join(root, ...parentRel.split('/'));
|
|
431
|
+
for (const name of readdirSync(parent).sort()) {
|
|
432
|
+
const childRel = `${parentRel}/${name}`;
|
|
433
|
+
assertRelativePathTopology(root, childRel);
|
|
434
|
+
const child = join(root, ...childRel.split('/'));
|
|
435
|
+
const stat = lstatSync(child);
|
|
436
|
+
if (stat.isDirectory()) pending.push(childRel);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function worktreeFingerprint(root, relPath, options = {}, diagnostics = {
|
|
442
|
+
unsafeWorktree: [], hiddenIndex: [], nestedMetadata: [], nestedFingerprints: {},
|
|
443
|
+
}) {
|
|
444
|
+
const path = join(root, ...posix(relPath).split('/'));
|
|
445
|
+
try {
|
|
446
|
+
lstatSync(path);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (error?.code === 'ENOENT') return 'missing';
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
assertRelativePathTopology(root, relPath);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
diagnostics.unsafeWorktree.push(posix(relPath));
|
|
455
|
+
return `unsafe:${error.code}:${createHash('sha256').update(error.message).digest('hex')}`;
|
|
456
|
+
}
|
|
457
|
+
const stat = lstatSync(path);
|
|
458
|
+
if (stat.isSymbolicLink()) {
|
|
459
|
+
diagnostics.unsafeWorktree.push(posix(relPath));
|
|
460
|
+
return `symlink:${readlinkSync(path)}`;
|
|
461
|
+
}
|
|
462
|
+
let gitMarker = null;
|
|
463
|
+
if (stat.isDirectory()) {
|
|
464
|
+
try {
|
|
465
|
+
gitMarker = lstatSync(join(path, '.git'));
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
468
|
+
}
|
|
469
|
+
if (gitMarker?.isSymbolicLink()) {
|
|
470
|
+
diagnostics.unsafeWorktree.push(`${posix(relPath)}/.git`);
|
|
471
|
+
return `unsafe-gitlink-marker:${readlinkSync(join(path, '.git'))}`;
|
|
472
|
+
}
|
|
473
|
+
if (gitMarker && !gitMarker.isFile() && !gitMarker.isDirectory()) {
|
|
474
|
+
diagnostics.unsafeWorktree.push(`${posix(relPath)}/.git`);
|
|
475
|
+
return `unsafe-gitlink-marker:${gitMarker.mode}`;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (stat.isDirectory() && gitMarker) {
|
|
479
|
+
const childRoot = realpathSync.native(path);
|
|
480
|
+
const nested = captureGitSnapshot(path, {
|
|
481
|
+
...nestedSnapshotOptions(options, relPath),
|
|
482
|
+
_gitlinkDepth: Number(options._gitlinkDepth || 0) + 1,
|
|
483
|
+
_expectedGitlinkRoot: childRoot,
|
|
484
|
+
});
|
|
485
|
+
for (const unsafe of nested.unsafe_worktree_paths || []) {
|
|
486
|
+
diagnostics.unsafeWorktree.push(`${posix(relPath)}/${unsafe}`);
|
|
487
|
+
}
|
|
488
|
+
for (const unsafe of nested.unsafe_git_metadata_paths || []) {
|
|
489
|
+
diagnostics.unsafeWorktree.push(`${posix(relPath)}/.git:${unsafe}`);
|
|
490
|
+
}
|
|
491
|
+
for (const hidden of nested.hidden_index_paths || []) {
|
|
492
|
+
diagnostics.hiddenIndex.push(`${posix(relPath)}/${hidden}`);
|
|
493
|
+
}
|
|
494
|
+
for (const [nestedPath, fingerprint] of Object.entries(nested.fingerprints || {})) {
|
|
495
|
+
diagnostics.nestedFingerprints[`${posix(relPath)}/${nestedPath}`] = fingerprint;
|
|
496
|
+
}
|
|
497
|
+
diagnostics.nestedMetadata.push([
|
|
498
|
+
posix(relPath),
|
|
499
|
+
nested.git_metadata_fingerprint,
|
|
500
|
+
nested.hidden_index_paths || [],
|
|
501
|
+
]);
|
|
502
|
+
return `gitlink:${createHash('sha256').update(JSON.stringify(nested)).digest('hex')}`;
|
|
503
|
+
}
|
|
504
|
+
if (!stat.isFile()) return `other:${stat.mode}`;
|
|
505
|
+
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
506
|
+
return `file:${stat.mode}:${hash}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function captureGitSnapshot(projectRoot, options = {}) {
|
|
510
|
+
const start = resolve(projectRoot);
|
|
511
|
+
const root = realpathSync.native(resolve(String(git(start, ['rev-parse', '--show-toplevel'], options)).trim()));
|
|
512
|
+
const canonicalRoot = canonicalFsPath(root);
|
|
513
|
+
const expectedRoot = options._expectedGitlinkRoot ? canonicalFsPath(options._expectedGitlinkRoot) : '';
|
|
514
|
+
if (expectedRoot && canonicalRoot !== expectedRoot) {
|
|
515
|
+
const error = new Error(`gitlink redireciona para outro worktree: ${root}`);
|
|
516
|
+
error.code = 'FLOW_GITLINK_TOPOLOGY';
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
const depth = Number(options._gitlinkDepth || 0);
|
|
520
|
+
const seen = new Set(Array.isArray(options._gitlinkSeen) ? options._gitlinkSeen : []);
|
|
521
|
+
if (!Number.isSafeInteger(depth) || depth < 0 || depth > 8 || seen.has(canonicalRoot) || seen.size >= 64) {
|
|
522
|
+
const error = new Error(`topologia de gitlinks cíclica ou excessiva: ${root}`);
|
|
523
|
+
error.code = 'FLOW_GITLINK_TOPOLOGY';
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
seen.add(canonicalRoot);
|
|
527
|
+
options = { ...options, _gitlinkDepth: depth, _gitlinkSeen: [...seen] };
|
|
528
|
+
const head = String(git(root, ['rev-parse', '--verify', 'HEAD'], options)).trim();
|
|
529
|
+
const status = git(root, ['status', '--porcelain=v2', '-z', '--untracked-files=all'], { ...options, binary: true });
|
|
530
|
+
const fingerprints = {};
|
|
531
|
+
const dirtyPaths = new Set();
|
|
532
|
+
const diagnostics = {
|
|
533
|
+
unsafeWorktree: [], hiddenIndex: [], nestedMetadata: [], nestedFingerprints: {},
|
|
534
|
+
};
|
|
535
|
+
for (const record of parseStatus(Buffer.from(status).toString('utf8'))) {
|
|
536
|
+
const paths = record.paths.map(posix);
|
|
537
|
+
for (const path of paths) dirtyPaths.add(path);
|
|
538
|
+
const worktree = paths.map((path) => [path, worktreeFingerprint(root, path, options, diagnostics)]);
|
|
539
|
+
const fingerprint = createHash('sha256')
|
|
540
|
+
.update(JSON.stringify({ raw: record.raw, worktree }))
|
|
541
|
+
.digest('hex');
|
|
542
|
+
for (const path of paths) fingerprints[path] = fingerprint;
|
|
543
|
+
}
|
|
544
|
+
// A clean gitlink is absent from `git status`; enumerate the index so metadata-only
|
|
545
|
+
// drift inside every nested repository remains visible throughout the FLOW.
|
|
546
|
+
for (const path of trackedGitlinks(root, options)) {
|
|
547
|
+
if (Object.hasOwn(fingerprints, path)) continue;
|
|
548
|
+
fingerprints[path] = createHash('sha256')
|
|
549
|
+
.update(`gitlink-index:${worktreeFingerprint(root, path, options, diagnostics)}`)
|
|
550
|
+
.digest('hex');
|
|
551
|
+
}
|
|
552
|
+
Object.assign(fingerprints, diagnostics.nestedFingerprints);
|
|
553
|
+
const ignoredPathspecs = Array.isArray(options.ignoredPathspecs) ? options.ignoredPathspecs : [];
|
|
554
|
+
if (ignoredPathspecs.length) {
|
|
555
|
+
const ignored = git(root, [
|
|
556
|
+
'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...ignoredPathspecs,
|
|
557
|
+
], { ...options, binary: true });
|
|
558
|
+
for (const rawPath of Buffer.from(ignored).toString('utf8').split('\0').filter(Boolean)) {
|
|
559
|
+
const path = posix(rawPath);
|
|
560
|
+
if (typeof options.ignoredPathFilter === 'function' && !options.ignoredPathFilter(path)) continue;
|
|
561
|
+
dirtyPaths.add(path);
|
|
562
|
+
const fingerprint = createHash('sha256')
|
|
563
|
+
.update(`ignored:${worktreeFingerprint(root, path, options, diagnostics)}`)
|
|
564
|
+
.digest('hex');
|
|
565
|
+
fingerprints[path] = fingerprint;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const metadata = gitMetadataSnapshot(root, options);
|
|
569
|
+
const ownHiddenIndex = hiddenIndexPaths(root, options);
|
|
570
|
+
return {
|
|
571
|
+
schema_version: 1,
|
|
572
|
+
root,
|
|
573
|
+
head,
|
|
574
|
+
fingerprints,
|
|
575
|
+
dirty_paths: [...dirtyPaths].sort(),
|
|
576
|
+
git_metadata_fingerprint: createHash('sha256').update(JSON.stringify([
|
|
577
|
+
metadata.fingerprint,
|
|
578
|
+
diagnostics.nestedMetadata.sort(([left], [right]) => left.localeCompare(right)),
|
|
579
|
+
])).digest('hex'),
|
|
580
|
+
unsafe_git_metadata_paths: metadata.unsafePaths,
|
|
581
|
+
hidden_index_paths: [...new Set([...ownHiddenIndex, ...diagnostics.hiddenIndex])].sort(),
|
|
582
|
+
unsafe_worktree_paths: [...new Set(diagnostics.unsafeWorktree)].sort(),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function diffGitSnapshots(before, after) {
|
|
587
|
+
const paths = new Set([
|
|
588
|
+
...Object.keys(before?.fingerprints || {}),
|
|
589
|
+
...Object.keys(after?.fingerprints || {}),
|
|
590
|
+
...Object.keys(before?.protected_physical_fingerprints || {}),
|
|
591
|
+
...Object.keys(after?.protected_physical_fingerprints || {}),
|
|
592
|
+
]);
|
|
593
|
+
const changedPaths = [...paths]
|
|
594
|
+
.filter((path) => before?.fingerprints?.[path] !== after?.fingerprints?.[path]
|
|
595
|
+
|| before?.protected_physical_fingerprints?.[path]
|
|
596
|
+
!== after?.protected_physical_fingerprints?.[path])
|
|
597
|
+
.sort();
|
|
598
|
+
const beforeRoot = resolve(String(before?.root || ''));
|
|
599
|
+
const afterRoot = resolve(String(after?.root || ''));
|
|
600
|
+
const rootChanged = process.platform === 'win32'
|
|
601
|
+
? beforeRoot.toLowerCase() !== afterRoot.toLowerCase()
|
|
602
|
+
: beforeRoot !== afterRoot;
|
|
603
|
+
return {
|
|
604
|
+
rootChanged,
|
|
605
|
+
headChanged: before?.head !== after?.head,
|
|
606
|
+
metadataChanged: before?.git_metadata_fingerprint !== after?.git_metadata_fingerprint
|
|
607
|
+
|| JSON.stringify(before?.hidden_index_paths || []) !== JSON.stringify(after?.hidden_index_paths || []),
|
|
608
|
+
changedPaths,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function normalizeAllowedPaths(projectRoot, gitRoot, paths) {
|
|
613
|
+
// Git may expand an 8.3 Windows path while Node keeps the short spelling from
|
|
614
|
+
// %TEMP%. Canonicalize both roots before comparing them so the same directory
|
|
615
|
+
// is not mistaken for an escape from the repository.
|
|
616
|
+
const project = realpathSync.native(resolve(projectRoot));
|
|
617
|
+
const root = realpathSync.native(resolve(gitRoot));
|
|
618
|
+
const normalized = (paths || []).map((raw) => {
|
|
619
|
+
const value = String(raw || '').trim();
|
|
620
|
+
if (!value) throw new TypeError('path permitido vazio');
|
|
621
|
+
if (isAbsolute(value)) throw new TypeError(`path permitido deve ser relativo: ${value}`);
|
|
622
|
+
const prefix = /(?:[\\/]\*\*|[\\/])$/.test(value);
|
|
623
|
+
const withoutGlob = value.replace(/[\\/]\*\*$/, '').replace(/[\\/]$/, '');
|
|
624
|
+
const absolute = resolve(project, withoutGlob);
|
|
625
|
+
const fromProject = relative(project, absolute);
|
|
626
|
+
if (fromProject === '..' || fromProject.startsWith(`..${sep}`) || isAbsolute(fromProject)) {
|
|
627
|
+
throw new TypeError(`path fora do projeto: ${value}`);
|
|
628
|
+
}
|
|
629
|
+
const fromGit = relative(root, absolute);
|
|
630
|
+
if (!fromGit || fromGit === '..' || fromGit.startsWith(`..${sep}`) || isAbsolute(fromGit)) {
|
|
631
|
+
throw new TypeError(`path fora do repositório Git: ${value}`);
|
|
632
|
+
}
|
|
633
|
+
const gitRelative = posix(fromGit);
|
|
634
|
+
if (gitRelative.split('/').some((segment) => segment.toLowerCase() === '.git')) {
|
|
635
|
+
throw new TypeError(`metadados Git não são permitidos no FLOW: ${value}`);
|
|
636
|
+
}
|
|
637
|
+
return `${gitRelative}${prefix ? '/**' : ''}`;
|
|
638
|
+
});
|
|
639
|
+
return [...new Set(normalized)].sort();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function assertAllowedPathTopology(gitRoot, allowedPaths, changedPaths = []) {
|
|
643
|
+
const root = realpathSync.native(resolve(gitRoot));
|
|
644
|
+
for (const allowed of allowedPaths || []) {
|
|
645
|
+
const relPath = posix(allowed).replace(/\/\*\*$/, '');
|
|
646
|
+
try {
|
|
647
|
+
assertRelativePathTopology(root, relPath);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error?.code !== 'FLOW_PATH_TOPOLOGY') throw error;
|
|
650
|
+
throw topologyError(error.message.replace('path FLOW', 'path permitido'));
|
|
651
|
+
}
|
|
652
|
+
assertAllowedTreeTopology(root, allowed);
|
|
653
|
+
}
|
|
654
|
+
for (const changed of changedPaths || []) {
|
|
655
|
+
assertRelativePathTopology(root, changed);
|
|
656
|
+
}
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function assertPathRootTopology(gitRoot, paths) {
|
|
661
|
+
const root = realpathSync.native(resolve(gitRoot));
|
|
662
|
+
for (const path of paths || []) assertRelativePathTopology(root, path);
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function pathAllowed(path, allowlist) {
|
|
667
|
+
const candidate = process.platform === 'win32' ? posix(path).toLowerCase() : posix(path);
|
|
668
|
+
return (allowlist || []).some((allowed) => {
|
|
669
|
+
const normalized = process.platform === 'win32' ? posix(allowed).toLowerCase() : posix(allowed);
|
|
670
|
+
if (normalized.endsWith('/**')) {
|
|
671
|
+
const prefix = normalized.slice(0, -3);
|
|
672
|
+
return candidate === prefix || candidate.startsWith(`${prefix}/`);
|
|
673
|
+
}
|
|
674
|
+
return candidate === normalized;
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function runGitDiffCheck(gitRoot, { spawn = spawnSync, paths = [] } = {}) {
|
|
679
|
+
const cwd = resolve(gitRoot);
|
|
680
|
+
const selected = paths || [];
|
|
681
|
+
const result = spawn('git', ['diff', '--check', 'HEAD', '--', ...selected], {
|
|
682
|
+
cwd, encoding: 'utf8', windowsHide: true,
|
|
683
|
+
});
|
|
684
|
+
const baseOutput = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
685
|
+
if (result.error || result.status !== 0) {
|
|
686
|
+
return { ok: false, status: result.status ?? 1, output: baseOutput };
|
|
687
|
+
}
|
|
688
|
+
const outputs = [];
|
|
689
|
+
|
|
690
|
+
let candidates = selected;
|
|
691
|
+
if (!candidates.length) {
|
|
692
|
+
const untracked = spawn('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
|
|
693
|
+
cwd, encoding: 'utf8', windowsHide: true,
|
|
694
|
+
});
|
|
695
|
+
if (untracked.error || untracked.status !== 0) {
|
|
696
|
+
const output = `${untracked.stdout || ''}${untracked.stderr || ''}`.trim();
|
|
697
|
+
return { ok: false, status: untracked.status ?? 1, output };
|
|
698
|
+
}
|
|
699
|
+
candidates = String(untracked.stdout || '').split('\0').filter(Boolean);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const path of candidates) {
|
|
703
|
+
const tracked = spawn('git', ['ls-files', '--error-unmatch', '--', path], {
|
|
704
|
+
cwd, encoding: 'utf8', windowsHide: true,
|
|
705
|
+
});
|
|
706
|
+
if (!tracked.error && tracked.status === 0) continue;
|
|
707
|
+
const check = spawn('git', ['diff', '--no-index', '--check', '--', '/dev/null', path], {
|
|
708
|
+
cwd, encoding: 'utf8', windowsHide: true,
|
|
709
|
+
});
|
|
710
|
+
const output = `${check.stdout || ''}${check.stderr || ''}`.trim();
|
|
711
|
+
const diagnostics = output.split(/\r?\n/)
|
|
712
|
+
.filter((line) => line && !/^warning: .*\b(?:LF|CRLF) will be replaced by (?:LF|CRLF)\b/i.test(line))
|
|
713
|
+
.join('\n');
|
|
714
|
+
// --no-index returns 1 for an ordinary difference. Only diagnostics/output,
|
|
715
|
+
// execution errors, or status >1 represent a failed whitespace check.
|
|
716
|
+
if (diagnostics) outputs.push(diagnostics);
|
|
717
|
+
if (check.error || ![0, 1].includes(check.status)) {
|
|
718
|
+
return { ok: false, status: check.status ?? 1, output: outputs.join('\n') };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return { ok: outputs.length === 0, status: outputs.length ? 1 : 0, output: outputs.join('\n') };
|
|
722
|
+
}
|