@pathbit/agentdevkit 0.0.17 → 0.0.19
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/.pbai4devs/commands/sincronizar/COMMAND.md +6 -4
- package/.pbai4devs/scripts/lib/gitignore.mjs +100 -43
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +12 -7
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +70 -31
- package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +155 -17
- package/.pbai4devs/scripts/sincronizar-agentes/tests/PRESERVATION.mjs +306 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +11 -3
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +23 -6
- package/.pbai4devs/templates/gitignore/TEMPLATE.md +14 -7
- package/.pbai4devs/templates/gitignore/shared.ignore +1 -0
- package/CHANGELOG.md +11 -0
- package/README.en.md +1 -1
- package/README.es.md +2 -2
- package/docs/guides/cli.md +48 -15
- package/docs/guides/installation.md +49 -23
- package/docs/guides/troubleshooting.md +3 -2
- package/docs/product/design.md +6 -6
- package/docs/specs/README.md +2 -0
- package/docs/specs/client-ignore/SPEC.md +21 -0
- package/docs/specs/safe-synchronization/SPEC.md +21 -0
- package/package.json +1 -1
- package/src/i18n.mjs +10 -0
- package/src/install.mjs +227 -28
package/src/install.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import { synchronize, isReference } from '../.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
|
|
6
|
-
import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore,
|
|
7
|
+
import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, indexContentMatches } from '../.pbai4devs/scripts/lib/gitignore.mjs';
|
|
7
8
|
import { nativePathBoundary } from '../.pbai4devs/scripts/lib/native-paths.mjs';
|
|
8
9
|
import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, managedBlockDigest, mergeManagedContent, stripManagedContent } from '../.pbai4devs/scripts/lib/managed-content.mjs';
|
|
9
10
|
import { extractTomlManagedBlock, tomlManagedBlockDigest, mergeTomlContent, stripTomlContent } from '../.pbai4devs/scripts/lib/managed-toml.mjs';
|
|
@@ -15,6 +16,122 @@ const hash = value => createHash('sha256').update(value).digest('hex');
|
|
|
15
16
|
const portable = value => value.split(sep).join('/');
|
|
16
17
|
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
17
18
|
const stat = path => { try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
19
|
+
|
|
20
|
+
// Protect staged human bytes that differ from the working tree, without
|
|
21
|
+
// treating an unchanged HEAD entry as a staged edit or rewriting the index.
|
|
22
|
+
function stagedDivergences(root) {
|
|
23
|
+
let anchor = resolve(root);
|
|
24
|
+
while (!existsSync(anchor)) {
|
|
25
|
+
const parent = dirname(anchor);
|
|
26
|
+
if (parent === anchor) break;
|
|
27
|
+
anchor = parent;
|
|
28
|
+
}
|
|
29
|
+
const prefix = portable(relative(anchor, root));
|
|
30
|
+
let hasGitMarker = false;
|
|
31
|
+
for (let current = anchor; ; current = dirname(current)) {
|
|
32
|
+
if (stat(join(current, '.git'))) { hasGitMarker = true; break; }
|
|
33
|
+
if (dirname(current) === current) break;
|
|
34
|
+
}
|
|
35
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
36
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_EXTERNAL_DIFF']) delete env[key];
|
|
37
|
+
const run = args => spawnSync('git', ['-C', anchor, '-c', 'core.fsmonitor=false', ...args], {
|
|
38
|
+
env, timeout: 30000, windowsHide: true, maxBuffer: 16 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
const repository = run(['rev-parse', '--is-inside-work-tree']);
|
|
41
|
+
if (repository.error || repository.status !== 0) {
|
|
42
|
+
if (hasGitMarker) fail('E_GIT', 'Unable to inspect the existing Git repository safely.');
|
|
43
|
+
if (repository.error?.code === 'ENOENT' || !repository.error && repository.status !== 0) return new Set();
|
|
44
|
+
}
|
|
45
|
+
if (repository.error || repository.status !== 0 || repository.stdout.toString('utf8').trim() !== 'true') fail('E_GIT', 'Unable to inspect Git worktree.');
|
|
46
|
+
// `git diff` against the worktree respects assume-unchanged and
|
|
47
|
+
// skip-worktree, so compare the actual bytes to the index blobs instead.
|
|
48
|
+
const stagedResult = run(['diff', '--cached', '--no-renames', '--no-ext-diff', '--no-textconv', '--name-only', '--relative', '-z']);
|
|
49
|
+
const indexResult = run(['ls-files', '--stage', '-z']);
|
|
50
|
+
if (stagedResult.error || stagedResult.status !== 0 || indexResult.error || indexResult.status !== 0) fail('E_GIT', 'Unable to inspect staged changes safely.');
|
|
51
|
+
const decode = buffer => {
|
|
52
|
+
const value = buffer.toString('utf8');
|
|
53
|
+
if (!Buffer.from(value, 'utf8').equals(buffer)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const records = buffer => {
|
|
57
|
+
const result = []; let start = 0;
|
|
58
|
+
for (let i = 0; i < buffer.length; i++) if (buffer[i] === 0) { result.push(buffer.subarray(start, i)); start = i + 1; }
|
|
59
|
+
if (start !== buffer.length) fail('E_GIT', 'Unable to parse Git output safely.');
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
const paths = new Set(records(stagedResult.stdout).map(decode));
|
|
63
|
+
const entries = new Map();
|
|
64
|
+
for (const bytes of records(indexResult.stdout)) {
|
|
65
|
+
const tab = bytes.indexOf(9);
|
|
66
|
+
const record = bytes.subarray(0, tab).toString('ascii');
|
|
67
|
+
if (tab < 0) fail('E_GIT', 'Unable to parse the Git index safely.');
|
|
68
|
+
const match = /^(\d+) ([0-9a-f]+) ([0-3])$/.exec(record);
|
|
69
|
+
if (!match) fail('E_GIT', 'Unable to parse the Git index safely.');
|
|
70
|
+
const rawName = bytes.subarray(tab + 1);
|
|
71
|
+
const name = rawName.toString('utf8');
|
|
72
|
+
if (!paths.has(name)) continue;
|
|
73
|
+
if (!Buffer.from(name, 'utf8').equals(rawName)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
|
|
74
|
+
if (entries.has(name) || match[3] !== '0') entries.set(name, null);
|
|
75
|
+
else entries.set(name, { mode: match[1], oid: match[2] });
|
|
76
|
+
}
|
|
77
|
+
const divergent = new Set();
|
|
78
|
+
divergent.deleted = new Set();
|
|
79
|
+
for (const name of paths) {
|
|
80
|
+
if (prefix && !name.startsWith(`${prefix}/`)) continue;
|
|
81
|
+
const path = prefix ? name.slice(prefix.length + 1) : name;
|
|
82
|
+
const entry = entries.get(name);
|
|
83
|
+
// Distinguish a staged deletion from unresolved index stages.
|
|
84
|
+
if (!entries.has(name)) { divergent.add(path); divergent.deleted.add(path); continue; }
|
|
85
|
+
if (!entry || !['100644', '100755'].includes(entry.mode) || ![40, 64].includes(entry.oid.length)) { divergent.add(path); continue; }
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
const file = safePath(root, path), info = stat(file);
|
|
89
|
+
if (!info?.isFile()) { divergent.add(path); continue; }
|
|
90
|
+
fd = openSync(file, 'r');
|
|
91
|
+
const before = fstatSync(fd);
|
|
92
|
+
const digest = createHash(entry.oid.length === 64 ? 'sha256' : 'sha1').update(`blob ${before.size}\0`);
|
|
93
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
94
|
+
let count = 0, n;
|
|
95
|
+
while ((n = readSync(fd, buffer, 0, buffer.length, null)) !== 0) { digest.update(buffer.subarray(0, n)); count += n; }
|
|
96
|
+
const after = fstatSync(fd);
|
|
97
|
+
if (count !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { divergent.add(path); continue; }
|
|
98
|
+
if (digest.digest('hex') !== entry.oid) {
|
|
99
|
+
const indexed = run(['cat-file', 'blob', entry.oid]);
|
|
100
|
+
const working = readFileSync(file), latest = fstatSync(fd);
|
|
101
|
+
if (indexed.error || indexed.status !== 0) fail('E_GIT', 'Unable to inspect staged content safely.');
|
|
102
|
+
if (latest.size !== after.size || latest.mtimeMs !== after.mtimeMs || !indexContentMatches(root, path, working, indexed.stdout)) divergent.add(path);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Treat paths that cannot be inspected (including symbolic links) as
|
|
106
|
+
// divergent; the normal safety checks still reject unsafe writes.
|
|
107
|
+
if (error.code === 'E_SYMLINK' || error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'E_PATH') divergent.add(path);
|
|
108
|
+
else throw error;
|
|
109
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
110
|
+
}
|
|
111
|
+
return divergent;
|
|
112
|
+
}
|
|
113
|
+
// A staged name may use another case for the same path on macOS or Windows.
|
|
114
|
+
// Probe an existing directory only; do not create files during a dry run.
|
|
115
|
+
function stagedContains(root, paths, path) {
|
|
116
|
+
if (paths.has(path)) return true;
|
|
117
|
+
let current = root;
|
|
118
|
+
while (!stat(current) && dirname(current) !== current) current = dirname(current);
|
|
119
|
+
while (dirname(current) !== current) {
|
|
120
|
+
const base = current.slice(dirname(current).length + 1);
|
|
121
|
+
const index = base.search(/[A-Za-z]/);
|
|
122
|
+
if (index >= 0) {
|
|
123
|
+
const letter = base[index];
|
|
124
|
+
const other = letter === letter.toUpperCase() ? letter.toLowerCase() : letter.toUpperCase();
|
|
125
|
+
const alternative = join(dirname(current), `${base.slice(0, index)}${other}${base.slice(index + 1)}`);
|
|
126
|
+
const a = stat(current), b = stat(alternative);
|
|
127
|
+
if (!a || !b || a.dev !== b.dev || a.ino !== b.ino) return false;
|
|
128
|
+
return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
|
|
129
|
+
}
|
|
130
|
+
current = dirname(current);
|
|
131
|
+
}
|
|
132
|
+
// If no existing name can be probed, preserve on ambiguity.
|
|
133
|
+
return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
|
|
134
|
+
}
|
|
18
135
|
const ignored = new Set(['.git', '.local', '.cache', 'node_modules', '.agent-tests', 'opencode-loop']);
|
|
19
136
|
const reservedTargets = new Set(['.git', 'node_modules', '.pbai4devs']);
|
|
20
137
|
const checkTarget = path => {
|
|
@@ -71,7 +188,10 @@ function walk(root, path = '', filter = () => true) {
|
|
|
71
188
|
for (const name of readdirSync(directory).sort()) {
|
|
72
189
|
const child = path ? `${path}/${name}` : name;
|
|
73
190
|
const info = lstatSync(join(root, child));
|
|
74
|
-
if (info.isSymbolicLink())
|
|
191
|
+
if (info.isSymbolicLink()) {
|
|
192
|
+
if (!filter(child, false) && !filter(child, true)) continue;
|
|
193
|
+
fail('E_SYMLINK', `Symbolic link refused: ${child}`);
|
|
194
|
+
}
|
|
75
195
|
if (!filter(child, info.isDirectory())) continue;
|
|
76
196
|
if (info.isDirectory()) files.push(...walk(root, child, filter));
|
|
77
197
|
else if (info.isFile()) files.push(child);
|
|
@@ -109,7 +229,7 @@ function read(root, path) {
|
|
|
109
229
|
}
|
|
110
230
|
|
|
111
231
|
function allowedManaged(path) {
|
|
112
|
-
if (
|
|
232
|
+
if (['.pbai4devs/estado/README.md', '.pbai4devs/adapters/README.md'].includes(path)) return true;
|
|
113
233
|
if (canonicalEntrypoint(path) || path === '.gitignore') return true;
|
|
114
234
|
if (nativeMarkdown(path) || nativeToml(path)) return true;
|
|
115
235
|
if (['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf'].some(name => path === `.pbai4devs/${name}`)) return true;
|
|
@@ -316,6 +436,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
316
436
|
?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
|
|
317
437
|
const preserved = new Map();
|
|
318
438
|
const recordPreserved = (path, reason) => { preserved.set(path, { path, reason }); return true; };
|
|
439
|
+
const stagedHuman = stagedDivergences(target);
|
|
319
440
|
const native = path => canonicalEntrypoint(path) || path === 'README.md' || adapterRoots.some(root => path === root || path.startsWith(`${root}/`));
|
|
320
441
|
const boundary = path => {
|
|
321
442
|
if (!native(path)) return false;
|
|
@@ -353,8 +474,27 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
353
474
|
}
|
|
354
475
|
const supplied = new Set(bundled);
|
|
355
476
|
const internal = new Set(['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf', '.gitignore', 'state/README.md', 'estado/README.md']);
|
|
356
|
-
|
|
357
|
-
|
|
477
|
+
// A pathname, matching contents, or --force alone do not establish that a
|
|
478
|
+
// project's hub source belongs to the kit. Restore human files *before*
|
|
479
|
+
// deriving native pointers so that the staged catalogue describes the
|
|
480
|
+
// source that will actually remain in the project.
|
|
481
|
+
const preservedHub = new Set();
|
|
482
|
+
for (const relative of existing) {
|
|
483
|
+
const path = `.pbai4devs/${relative}`;
|
|
484
|
+
if (internal.has(relative)) continue;
|
|
485
|
+
const current = read(target, path);
|
|
486
|
+
if (!current || previous?.files?.[path] === hash(current) && !stagedContains(target, stagedHuman, path)) continue;
|
|
487
|
+
if (supplied.has(relative)) write(stage, path, current);
|
|
488
|
+
preservedHub.add(path);
|
|
489
|
+
recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : previous?.files?.[path] ? 'edited-managed-file' : 'existing-file');
|
|
490
|
+
}
|
|
491
|
+
if (bundled.length) for (const [path, digest] of Object.entries(previous?.files || {})) {
|
|
492
|
+
if (!path.startsWith('.pbai4devs/') || supplied.has(path.slice(11)) || internal.has(path.slice(11))) continue;
|
|
493
|
+
const current = read(target, path);
|
|
494
|
+
if (current && (digest !== hash(current) || stagedContains(target, stagedHuman, path))) {
|
|
495
|
+
preservedHub.add(path);
|
|
496
|
+
recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : 'edited-managed-file');
|
|
497
|
+
} else rmSync(safePath(stage, path), { force: true });
|
|
358
498
|
}
|
|
359
499
|
for (const root of active.roots) {
|
|
360
500
|
if (preserveNative(`${root}/.gitignore`)) continue;
|
|
@@ -432,7 +572,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
432
572
|
const manifest = { schemaVersion: 1, version: bundled.length ? packageInfo.version : previous.version, language, files: {}, blocks: {} };
|
|
433
573
|
if (mode !== 'sync' || clients !== undefined || previous?.clients !== undefined) manifest.clients = selected;
|
|
434
574
|
for (const [path, content] of desired) {
|
|
435
|
-
if (path === 'README.md' || migratedHuman.has(path)) continue;
|
|
575
|
+
if (path === 'README.md' || migratedHuman.has(path) || preservedHub.has(path)) continue;
|
|
436
576
|
const block = blockDigest(path, content);
|
|
437
577
|
if (block) manifest.blocks[path] = block;
|
|
438
578
|
else manifest.files[path] = hash(content);
|
|
@@ -460,8 +600,8 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
460
600
|
if (aliasedEntrypoint(path)) continue;
|
|
461
601
|
const current = read(target, path); if (!current) continue;
|
|
462
602
|
const known = wholeRecords.some(record => record[path] === hash(current));
|
|
463
|
-
if (
|
|
464
|
-
if (!known)
|
|
603
|
+
if (stagedContains(target, stagedHuman, path)) { recordPreserved(path, 'staged-content-differs'); continue; }
|
|
604
|
+
if (!known) { recordPreserved(path, 'edited-managed-file'); continue; }
|
|
465
605
|
changes.push({ path, action: 'remove', content: null, beforeHash: hash(current) });
|
|
466
606
|
}
|
|
467
607
|
for (const [recordedPath, digest] of Object.entries(knownBlocks)) {
|
|
@@ -509,25 +649,58 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
509
649
|
if (preserved.has(full) || !known || !generatedReference(current)) { recordPreserved(full, 'existing-file'); continue; }
|
|
510
650
|
changes.push({ path: full, action: 'remove', content: null, beforeHash: hash(current) });
|
|
511
651
|
}
|
|
512
|
-
const legacy = '.pbai4devs/adapters/README.md',
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
652
|
+
const legacy = '.pbai4devs/adapters/README.md', legacyRoot = '.pbai4devs/adapters';
|
|
653
|
+
const legacyRootInfo = stat(join(target, legacyRoot));
|
|
654
|
+
const legacyInfo = legacyRootInfo?.isDirectory() ? stat(join(target, legacy)) : null;
|
|
655
|
+
if (legacyRootInfo && !legacyRootInfo.isDirectory()) recordPreserved(legacyRoot, legacyRootInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-file');
|
|
656
|
+
if (legacyInfo && !legacyInfo.isFile()) recordPreserved(legacy, legacyInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-directory');
|
|
657
|
+
const legacyContent = legacyInfo?.isFile() ? read(target, legacy) : null;
|
|
658
|
+
if (legacyContent && !changes.some(change => change.path === legacy)) {
|
|
659
|
+
if (!stagedContains(target, stagedHuman, legacy) && previous?.files?.[legacy] === hash(legacyContent)) changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
|
|
660
|
+
else recordPreserved(legacy, stagedContains(target, stagedHuman, legacy) ? 'staged-content-differs' : 'existing-file');
|
|
517
661
|
}
|
|
518
662
|
for (const item of migration?.files || []) changes.push({ path: item.path, action: 'remove', content: null, beforeHash: hash(item.content) });
|
|
663
|
+
// The generated catalogues must agree with the installed sources. A staged
|
|
664
|
+
// deletion has no indexed bytes to overwrite; allow their regeneration only
|
|
665
|
+
// when the current file exactly matches the previously owned digest.
|
|
666
|
+
const generatedCatalogues = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
|
|
667
|
+
for (const change of changes) if (change.action === 'update' && generatedCatalogues.has(change.path)
|
|
668
|
+
&& stagedContains(target, stagedHuman.deleted || new Set(), change.path)
|
|
669
|
+
&& change.beforeHash && previous?.files?.[change.path] === change.beforeHash) change.stagedDeletionAllowed = true;
|
|
670
|
+
for (const change of changes) {
|
|
671
|
+
if (!stagedContains(target, stagedHuman, change.path)) continue;
|
|
672
|
+
const current = read(target, change.path);
|
|
673
|
+
if (change.action === 'update' && current && change.content && nativeManagedText(change.path)) {
|
|
674
|
+
// Preserve every byte outside a known managed block. Appending a block
|
|
675
|
+
// is also safe when the complete existing file is an exact prefix.
|
|
676
|
+
if (!nativeToml(change.path) && change.content.subarray(0, current.length).equals(current)) {
|
|
677
|
+
try {
|
|
678
|
+
const digest = managedBlockDigest(change.content);
|
|
679
|
+
const stripped = digest && stripManagedContent(change.content, digest);
|
|
680
|
+
const extra = stripped?.slice(current.toString('utf8').length);
|
|
681
|
+
if (stripped?.startsWith(current.toString('utf8')) && extra !== undefined && /^[\r\n]*$/.test(extra)) change.stagedBlockAllowed = true;
|
|
682
|
+
} catch { /* Invalid or unproven block: keep the path blocked. */ }
|
|
683
|
+
} else if (knownBlocks[change.path]) {
|
|
684
|
+
try {
|
|
685
|
+
const strip = nativeToml(change.path) ? stripTomlContent : stripManagedContent;
|
|
686
|
+
if (Buffer.from(strip(current, knownBlocks[change.path])).equals(change.content)) change.stagedBlockAllowed = true;
|
|
687
|
+
} catch { /* An edited block is not safe to remove. */ }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (change.action === 'remove' && migration) {
|
|
691
|
+
const item = migration.files.find(file => file.path === change.path);
|
|
692
|
+
const copy = item && changes.find(candidate => candidate.path === item.target && candidate.action === 'create');
|
|
693
|
+
if (item && current?.equals(item.content) && copy?.content?.equals(item.content)
|
|
694
|
+
&& changes.indexOf(copy) < changes.indexOf(change)) change.stagedMigrationTarget = item.target;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const stagedChanges = changes.filter(change => stagedContains(target, stagedHuman, change.path) && !change.stagedDeletionAllowed && !change.stagedBlockAllowed && !change.stagedMigrationTarget);
|
|
698
|
+
if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
|
|
519
699
|
const retiredRoots = adapterRoots.filter(root => !active.roots.includes(root) && changes.some(change => change.action === 'remove' && change.path.startsWith(`${root}/`) && (previous?.files[change.path] || previous?.blocks[change.path])));
|
|
520
700
|
const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !mixedPaths.has(path) && !overlapsPreserved(path));
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
for (const path of Object.keys(previous?.blocks || {})) if (path.endsWith('/.gitignore') && adapterRoots.some(root => path === `${root}/.gitignore`)) {
|
|
525
|
-
const current = read(target, path);
|
|
526
|
-
if (current) { const source = current.toString('utf8'), block = extractBlock(source, '# BEGIN AGENT IGNORE', '# END AGENT IGNORE'); if (block && source.trim() === block.trim() && hash(block) === previous.blocks[path]) hashes[path] = hash(current); }
|
|
527
|
-
}
|
|
528
|
-
if (previous && !migration) hashes[statePath] = hash(read(target, statePath));
|
|
529
|
-
const untrack = existsSync(target) ? planUntrack(target, hashes) : null;
|
|
530
|
-
if (migration && untrack?.paths.length) fail('E_STATE', 'State migration would change the Git index; preserve and resolve tracked outputs first.');
|
|
701
|
+
// Ignore rules affect only untracked files. Never remove existing index
|
|
702
|
+
// entries on behalf of the project; staged bytes are checked separately.
|
|
703
|
+
const untrack = { root: target, paths: [], hashes: {}, index: '', conflicts: [] };
|
|
531
704
|
const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
|
|
532
705
|
const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
|
|
533
706
|
conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
|
|
@@ -539,6 +712,24 @@ export function applyPlan(plan) {
|
|
|
539
712
|
if (!plan.canApply) fail('E_CONFLICT', text(plan.language, 'conflict'));
|
|
540
713
|
assertLocalIgnoreUnchanged(plan.localIgnore);
|
|
541
714
|
const { target } = plan;
|
|
715
|
+
const stagedHuman = stagedDivergences(target);
|
|
716
|
+
const currentState = loadState(target);
|
|
717
|
+
const stagedChanges = plan.changes.filter(change => {
|
|
718
|
+
if (!stagedContains(target, stagedHuman, change.path)) return false;
|
|
719
|
+
const current = read(target, change.path);
|
|
720
|
+
if ((current ? hash(current) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
|
|
721
|
+
if (!current) return true;
|
|
722
|
+
if (change.stagedDeletionAllowed && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
|
|
723
|
+
&& currentState?.files?.[change.path] === change.beforeHash) return false;
|
|
724
|
+
if (change.stagedBlockAllowed) return false; // Exact source and planned bytes were checked above.
|
|
725
|
+
if (change.stagedMigrationTarget) {
|
|
726
|
+
const copy = plan.changes.find(candidate => candidate.path === change.stagedMigrationTarget && candidate.action === 'create');
|
|
727
|
+
if (copy?.content?.equals(current) && plan.changes.indexOf(copy) < plan.changes.indexOf(change)
|
|
728
|
+
&& !stat(safePath(target, change.stagedMigrationTarget))) return false;
|
|
729
|
+
}
|
|
730
|
+
return true;
|
|
731
|
+
});
|
|
732
|
+
if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
|
|
542
733
|
if (plan.migration) {
|
|
543
734
|
if (stat(safePath(target, '.pbai4devs/state'))) fail('E_CHANGED', 'State destination appeared after planning.');
|
|
544
735
|
for (const directory of plan.migration.directories) {
|
|
@@ -561,20 +752,29 @@ export function applyPlan(plan) {
|
|
|
561
752
|
for (const change of plan.changes) if ((before.get(change.path) ? hash(before.get(change.path)) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
|
|
562
753
|
for (const [path, content] of before) if (content) write(target, `${backup}/${path}`, content, beforeModes.get(path));
|
|
563
754
|
const applied = [], removedDirectories = [];
|
|
564
|
-
let untracked = false;
|
|
565
755
|
const assertOriginal = path => {
|
|
566
756
|
const file = safePath(target, path), info = stat(file), bytes = before.get(path);
|
|
567
757
|
if (bytes === null ? info !== null : !info?.isFile() || !readFileSync(file).equals(bytes) || (info.mode & 0o777) !== beforeModes.get(path)) fail('E_CHANGED', `File changed before writing: ${path}`);
|
|
568
758
|
};
|
|
569
759
|
try {
|
|
570
760
|
for (const change of plan.changes) assertOriginal(change.path);
|
|
571
|
-
if (plan.untrack?.paths.length) { applyUntrack(plan.untrack); untracked = true; }
|
|
572
761
|
for (const change of plan.changes) {
|
|
573
762
|
assertOriginal(change.path);
|
|
574
763
|
const file = safePath(target, change.path);
|
|
575
764
|
const content = change.content;
|
|
576
765
|
const signature = { path: change.path, hash: content === null ? null : hash(content) };
|
|
577
|
-
if (content === null) {
|
|
766
|
+
if (content === null) {
|
|
767
|
+
const migrated = plan.migration?.files.find(item => item.path === change.path);
|
|
768
|
+
if (migrated) {
|
|
769
|
+
const copy = plan.changes.find(candidate => candidate.path === migrated.target && candidate.action === 'create');
|
|
770
|
+
const copied = read(target, migrated.target);
|
|
771
|
+
if (!copy?.content || !copied?.equals(copy.content)
|
|
772
|
+
|| migrated.path !== legacyStatePath && !copied.equals(before.get(change.path))) {
|
|
773
|
+
fail('E_CHANGED', `Migrated copy changed before removing: ${change.path}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
rmSync(file, { force: true }); applied.push(signature);
|
|
777
|
+
}
|
|
578
778
|
else atomicWrite(target, change.path, content, beforeModes.get(change.path) ?? change.mode, mode => {
|
|
579
779
|
signature.mode = mode; applied.push(signature);
|
|
580
780
|
}, () => assertOriginal(change.path));
|
|
@@ -597,7 +797,7 @@ export function applyPlan(plan) {
|
|
|
597
797
|
const changesFallback = plan.changes.some(change => change.path === '.gitignore');
|
|
598
798
|
// Metadata is committed last; its writer rolls back partial writes itself.
|
|
599
799
|
const ignored = applyLocalIgnore(changesFallback ? planLocalIgnore(target, { outputs: plan.ownedOutputs }) : plan.localIgnore);
|
|
600
|
-
return { changed: Boolean(plan.changes.length || ignored.changed
|
|
800
|
+
return { changed: Boolean(plan.changes.length || ignored.changed), backup: [...before.values()].some(Boolean) ? backup : null };
|
|
601
801
|
} catch (error) {
|
|
602
802
|
const rollbackErrors = [], preserved = [];
|
|
603
803
|
for (const removed of [...removedDirectories].sort((a, b) => a.path.length - b.path.length)) {
|
|
@@ -638,7 +838,6 @@ export function applyPlan(plan) {
|
|
|
638
838
|
if (preserved.length) {
|
|
639
839
|
try { error.rollbackPreservedPaths = preserved; } catch { /* Preserve a exceção original. */ }
|
|
640
840
|
}
|
|
641
|
-
if (untracked) try { restoreUntrack(plan.untrack); } catch (failure) { rollbackErrors.push(failure); }
|
|
642
841
|
if (rollbackErrors.length) throw Object.assign(new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error }), { code: error.code || 'E_OPERATION' });
|
|
643
842
|
throw error;
|
|
644
843
|
}
|