@pathbit/agentdevkit 0.0.17 → 0.0.18
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 +46 -9
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +7 -0
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +70 -31
- package/.pbai4devs/scripts/sincronizar-agentes/tests/PRESERVATION.mjs +306 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +11 -3
- package/CHANGELOG.md +6 -0
- package/docs/guides/cli.md +46 -15
- package/docs/guides/installation.md +20 -8
- package/docs/specs/README.md +1 -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 +223 -14
|
@@ -4,14 +4,16 @@ description: Atualiza ou confere os apontamentos compartilhados dos agentes.
|
|
|
4
4
|
|
|
5
5
|
# Sincronizar
|
|
6
6
|
|
|
7
|
-
Entrada:
|
|
7
|
+
Entrada: este comando agêntico usa o script local com `--force` para reparar;
|
|
8
|
+
`--check` só confere. O `sync` da CLI usa as fontes locais e não implica
|
|
9
|
+
`--force`; `update` usa as fontes da versão do pacote executado.
|
|
8
10
|
Pré-requisitos: Node.js 24+ e manifesto `.pbai4devs/espelhos.conf` válido.
|
|
9
11
|
|
|
10
12
|
Siga [sincronizar-agentes](../../skills/projeto/sincronizar-agentes/SKILL.md)
|
|
11
13
|
e execute o script correspondente pelo seletor. A reconstrução repara
|
|
12
|
-
apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada
|
|
13
|
-
|
|
14
|
-
inclusive
|
|
14
|
+
apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada.
|
|
15
|
+
Recursos adicionais, configurações, pastas e links humanos são preservados,
|
|
16
|
+
inclusive em colisões de nomes. O backup local não substitui o histórico Git.
|
|
15
17
|
Após reparar, execute também `--check`.
|
|
16
18
|
|
|
17
19
|
Respeite os clientes registrados no manifesto. Para mudar a seleção salva,
|
|
@@ -98,10 +98,33 @@ function localBlock(current, first, last, body) {
|
|
|
98
98
|
return text.slice(0, start) + block + text.slice(finish);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function gitCommand(root, args, input) {
|
|
101
|
+
function gitCommand(root, args, input, encoding = 'utf8') {
|
|
102
102
|
const env = { ...process.env };
|
|
103
103
|
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
|
|
104
|
-
return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding
|
|
104
|
+
return spawnSync('git', [...(['ls-files', 'rm'].includes(args[0]) ? ['--literal-pathspecs'] : []), '-C', root, ...args], { input, encoding, env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Compare exact bytes first. Only accept Git's plain text CRLF conversion when
|
|
108
|
+
// effective attributes prove it applies and no custom transformation can run.
|
|
109
|
+
// This deliberately does not invoke hash-object or repository-defined filters.
|
|
110
|
+
export function indexContentMatches(root, path, working, indexed) {
|
|
111
|
+
if (working.equals(indexed)) return true;
|
|
112
|
+
const attributes = gitCommand(root, ['check-attr', '-z', 'text', 'eol', 'filter', 'working-tree-encoding', 'ident', '--', path]);
|
|
113
|
+
if (attributes.status !== 0) return false;
|
|
114
|
+
const parts = attributes.stdout.split('\0');
|
|
115
|
+
const values = {};
|
|
116
|
+
for (let i = 0; i + 2 < parts.length; i += 3) values[parts[i + 1]] = parts[i + 2];
|
|
117
|
+
const absent = value => value === 'unspecified' || value === 'unset';
|
|
118
|
+
if (!absent(values.filter) || !absent(values['working-tree-encoding']) || !absent(values.ident) || values.text === 'unset') return false;
|
|
119
|
+
const config = gitCommand(root, ['config', '--bool', 'core.autocrlf']);
|
|
120
|
+
if (config.status !== 0 && config.status !== 1) return false;
|
|
121
|
+
const textEnabled = values.text === 'set' || values.text === 'auto' || ['lf', 'crlf'].includes(values.eol) || config.status === 0 && config.stdout.trim() === 'true';
|
|
122
|
+
if (!textEnabled || !working.includes(13) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(working.toString('latin1'))) return false;
|
|
123
|
+
try {
|
|
124
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(working);
|
|
125
|
+
if (text.replaceAll('\r\n', '').includes('\r')) return false;
|
|
126
|
+
return Buffer.from(text.replaceAll('\r\n', '\n')).equals(indexed);
|
|
127
|
+
} catch { return false; }
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
function checkedOutput(path) {
|
|
@@ -502,25 +525,39 @@ export function planUntrack(root, expectedHashes = {}) {
|
|
|
502
525
|
if (!candidates.length || !localStat(root)) return { root, paths: [], hashes: {}, index: '', conflicts: [] };
|
|
503
526
|
const result = gitCommand(root, ['ls-files', '--stage', '-z']);
|
|
504
527
|
if (result.status !== 0) {
|
|
505
|
-
if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || ''))
|
|
528
|
+
if (result.error?.code === 'ENOENT' || /not a git repository/i.test(result.stderr || '')) {
|
|
529
|
+
// Fail closed if a repository exists even when Git cannot be launched.
|
|
530
|
+
let directory = root;
|
|
531
|
+
while (true) {
|
|
532
|
+
if (localStat(join(directory, '.git'))) throw new Error('Nao foi possivel consultar o Git deste projeto; indice e arquivos preservados.');
|
|
533
|
+
const parent = dirname(directory);
|
|
534
|
+
if (parent === directory) break;
|
|
535
|
+
directory = parent;
|
|
536
|
+
}
|
|
537
|
+
return { root, paths: [], hashes: {}, index: '', conflicts: [] };
|
|
538
|
+
}
|
|
506
539
|
throw new Error(result.error?.message || result.stderr || 'Falha ao ler indice Git.');
|
|
507
540
|
}
|
|
508
541
|
const conflicts = [];
|
|
509
542
|
const records = result.stdout.split('\0').filter(Boolean);
|
|
510
|
-
const
|
|
543
|
+
const folded = new Map();
|
|
544
|
+
for (const path of candidates) folded.set(path.toLowerCase(), [...(folded.get(path.toLowerCase()) || []), path]);
|
|
511
545
|
const matches = new Map();
|
|
512
546
|
for (const record of records) {
|
|
513
547
|
const position = record.indexOf('\t'), path = record.slice(position + 1);
|
|
514
|
-
if (position < 0
|
|
515
|
-
|
|
548
|
+
if (position < 0) continue;
|
|
549
|
+
for (const candidate of folded.get(path.toLowerCase()) || []) {
|
|
550
|
+
matches.set(candidate, [...(matches.get(candidate) || []), record]);
|
|
551
|
+
}
|
|
516
552
|
}
|
|
517
553
|
const paths = [...matches.keys()].filter(path => {
|
|
518
554
|
const file = checkedLocalPath(join(root, path));
|
|
519
555
|
const entries = matches.get(path);
|
|
520
556
|
const match = entries.length === 1 && entries[0].match(/^\d+ ([a-f0-9]+) 0\t/);
|
|
521
|
-
const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]]) : null;
|
|
522
|
-
const
|
|
523
|
-
const
|
|
557
|
+
const blob = match ? gitCommand(root, ['cat-file', 'blob', match[1]], undefined, null) : null;
|
|
558
|
+
const data = localStat(file) ? readFileSync(file) : null;
|
|
559
|
+
const owned = data && createHash('sha256').update(data).digest('hex') === expectedHashes[path];
|
|
560
|
+
const indexOwned = entries[0]?.slice(entries[0].indexOf('\t') + 1) === path && blob?.status === 0 && data && indexContentMatches(root, path, data, blob.stdout);
|
|
524
561
|
if (!owned || !indexOwned) conflicts.push(path);
|
|
525
562
|
return owned && indexOwned;
|
|
526
563
|
});
|
|
@@ -12,6 +12,9 @@ node --test .pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs
|
|
|
12
12
|
|
|
13
13
|
`--check` só confere; a execução normal atualiza os recursos. `--force` salva
|
|
14
14
|
conflitos em `.agent-tests/` antes de reparar os caminhos gerenciados.
|
|
15
|
+
O [comando agêntico](../../commands/sincronizar/COMMAND.md) usa `--force` por
|
|
16
|
+
padrão; executar este script diretamente não ativa essa opção. A CLI oferece
|
|
17
|
+
`sync --dry-run --json` para consultar o plano antes de aplicá-lo.
|
|
15
18
|
Recursos nativos humanos recebem um bloco curto com a referência ao hub.
|
|
16
19
|
Texto, frontmatter e nomes existentes são preservados. O estado guarda só o
|
|
17
20
|
hash desse bloco; edições externas continuam permitidas. Um bloco alterado
|
|
@@ -44,3 +47,7 @@ presença de um bloco gerenciado.
|
|
|
44
47
|
Entradas novas usam os templates; hashes em `state/derived.json` permitem
|
|
45
48
|
atualizar arquivos gerados e blocos separadamente, preservando alterações humanas.
|
|
46
49
|
`--check` também acusa derivados ainda rastreados; não altera o índice.
|
|
50
|
+
Apontamentos obsoletos podem ser removidos quando a propriedade é comprovada;
|
|
51
|
+
arquivos e pastas próprios, inclusive os adicionais dentro do mesmo recurso,
|
|
52
|
+
devem permanecer. Na CLI, confira `untrackedPaths` antes de aplicar: saídas
|
|
53
|
+
geradas comprovadas podem deixar de ser rastreadas, sem excluir seus bytes.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, posix, resolve } from 'node:path';
|
|
5
5
|
import { randomUUID, createHash } from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
|
|
7
8
|
import { buildCatalog } from '../lib/catalog.mjs';
|
|
8
9
|
import { resourcePath } from '../lib/resource-path.mjs';
|
|
@@ -10,7 +11,7 @@ import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
|
|
|
10
11
|
import { nativePathBoundary } from '../lib/native-paths.mjs';
|
|
11
12
|
import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, mergeManagedContent, stripManagedContent } from '../lib/managed-content.mjs';
|
|
12
13
|
import { mergeTomlContent, stripTomlContent } from '../lib/managed-toml.mjs';
|
|
13
|
-
import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
|
|
14
|
+
import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, planUntrack, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
|
|
14
15
|
import { stateDirectory } from '../lib/state-path.mjs';
|
|
15
16
|
|
|
16
17
|
const marker = '<!-- gerado: sincronizar-agentes -->';
|
|
@@ -79,6 +80,28 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
79
80
|
requireNode();
|
|
80
81
|
root = realpathSync(resolve(root));
|
|
81
82
|
const absolute = value => join(root, ...value.split('/'));
|
|
83
|
+
function assertGitReadableForChanges() {
|
|
84
|
+
// A project may be nested in a worktree; .git can be a file or directory.
|
|
85
|
+
// If that marker exists we must not treat an unavailable Git executable,
|
|
86
|
+
// an invalid repository or an unreadable index as a Git-free project.
|
|
87
|
+
let folder = root, found = false;
|
|
88
|
+
while (true) {
|
|
89
|
+
if (stat(join(folder, '.git'))) { found = true; break; }
|
|
90
|
+
const parent = dirname(folder);
|
|
91
|
+
if (parent === folder) break;
|
|
92
|
+
folder = parent;
|
|
93
|
+
}
|
|
94
|
+
if (!found) return false;
|
|
95
|
+
const env = { ...process.env };
|
|
96
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[key];
|
|
97
|
+
const probe = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
|
|
98
|
+
encoding: 'utf8', env, windowsHide: true, timeout: 20000,
|
|
99
|
+
});
|
|
100
|
+
if (probe.error || probe.status !== 0 || probe.stdout.trim() !== 'true') {
|
|
101
|
+
fail('Nao foi possivel consultar o Git deste projeto; sincronizacao bloqueada para preservar arquivos e indice.');
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
82
105
|
function safe(value) {
|
|
83
106
|
if (typeof value !== 'string' || !value || /^[A-Za-z]:/.test(value) || /[|\\\x00-\x1f\x7f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail(`Caminho invalido: ${value}`);
|
|
84
107
|
let current = root;
|
|
@@ -130,7 +153,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
130
153
|
const recorded = recordedHash === digest;
|
|
131
154
|
return isReference(text) && recorded;
|
|
132
155
|
};
|
|
133
|
-
const onlyReferences = value => { const files = scan(value); return files.length > 0 && ![...preserved.keys()].some(path => path === value || path.startsWith(`${value}/`)) && files.every(file => /\.(md|mdc|toml)$/i.test(file) && referenceFile(file)); };
|
|
134
156
|
const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
|
|
135
157
|
const agents = new Map();
|
|
136
158
|
const formats = new Map();
|
|
@@ -295,6 +317,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
295
317
|
const remove = new Set();
|
|
296
318
|
const backup = new Set();
|
|
297
319
|
const conflicts = new Set();
|
|
320
|
+
const protectedConflicts = new Set();
|
|
298
321
|
const expected = new Map();
|
|
299
322
|
const managedBlocks = {};
|
|
300
323
|
const generatedFiles = {};
|
|
@@ -307,17 +330,31 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
307
330
|
function planRemove(value, custom = false) {
|
|
308
331
|
safe(value);
|
|
309
332
|
if (!managed(value) || protectedPath(value)) fail(`Limpeza fora dos espelhos ou caminho protegido: ${value}`);
|
|
333
|
+
// Directories, unowned files and edited references can contain project
|
|
334
|
+
// data even when --force is used. Only a recorded, unchanged reference
|
|
335
|
+
// may be removed; empty directories are never removed recursively.
|
|
336
|
+
if (!stat(safe(value))?.isFile() || !referenceFile(value)) {
|
|
337
|
+
remember(value, 'existing-file');
|
|
338
|
+
if (custom) { conflicts.add(value); protectedConflicts.add(value); }
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
310
341
|
remove.add(value);
|
|
311
342
|
if (custom) backup.add(value);
|
|
312
343
|
}
|
|
313
344
|
function cleanScope(value) {
|
|
314
345
|
if (preserveBoundary(value)) return;
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
346
|
+
const info = stat(safe(value));
|
|
347
|
+
if (!info) return;
|
|
348
|
+
const files = info.isDirectory() ? scan(value) : [value];
|
|
349
|
+
if (info.isDirectory() && !files.length) { remember(value, 'existing-directory'); return; }
|
|
350
|
+
let hasHumanContent = false;
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
if (expected.has(file) || priorBlocks[file] || protectedPath(file)) continue;
|
|
353
|
+
if (referenceFile(file)) planRemove(file);
|
|
354
|
+
else if (claimed(file)) { remember(file, 'edited-generated-file'); hasHumanContent = true; }
|
|
355
|
+
else { remember(file); hasHumanContent = true; }
|
|
356
|
+
}
|
|
357
|
+
if (hasHumanContent && info.isDirectory()) remember(value);
|
|
321
358
|
}
|
|
322
359
|
const registries = new Map();
|
|
323
360
|
const discoveryOutputs = {};
|
|
@@ -551,6 +588,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
551
588
|
const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
|
|
552
589
|
const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
|
|
553
590
|
const configChanged = !configExists || configText !== finalConfig;
|
|
591
|
+
// Every planned write/removal may cover a different staged version. Compare
|
|
592
|
+
// the worktree bytes with the index before touching any path, even in force
|
|
593
|
+
// mode, and repeat immediately before applying the transaction.
|
|
594
|
+
const affected = sorted([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
|
|
595
|
+
const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
|
|
596
|
+
const affectedHashes = Object.fromEntries(affected.flatMap(value => {
|
|
597
|
+
const info = stat(safe(value));
|
|
598
|
+
if (info && !info.isFile()) return []; // A type conflict already blocks application.
|
|
599
|
+
return [[value, info ? createHash('sha256').update(readFileSync(absolute(value))).digest('hex') : emptyHash]];
|
|
600
|
+
}));
|
|
601
|
+
const hadGit = Object.keys(affectedHashes).length ? assertGitReadableForChanges() : false;
|
|
602
|
+
const affectedIndex = planUntrack(root, affectedHashes);
|
|
603
|
+
for (const path of affectedIndex.conflicts) {
|
|
604
|
+
conflicts.add(path);
|
|
605
|
+
remember(path, 'staged-content-preserved');
|
|
606
|
+
}
|
|
554
607
|
function capture(value) {
|
|
555
608
|
const file = safe(value), info = stat(file);
|
|
556
609
|
if (!info) return null;
|
|
@@ -576,15 +629,15 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
576
629
|
let parent = posix.dirname(value);
|
|
577
630
|
while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
|
|
578
631
|
}
|
|
579
|
-
const touched = new Set(), createdDirectories = new Set()
|
|
580
|
-
const prunedDirectories = new Map();
|
|
581
|
-
let restoring = false;
|
|
632
|
+
const touched = new Set(), createdDirectories = new Set();
|
|
582
633
|
const summary = { changed: changed.length + rootEntryChanges.length, stale: removals.length, conflicts: conflicts.size, missing: missing.length, legacy: legacy.length, registries: registries.size, gitignores: ignoreFiles.size, manifests: configExists ? 0 : 1, localIgnores: localStatus.changed + localStatus.missing.length + localStatus.trackedOutputs.length + (localStatus.status === 'no-git' ? 1 : 0) };
|
|
583
634
|
const dirty = Object.values(summary).some(Boolean);
|
|
584
635
|
output(`${summary.changed} desatualizadas; ${summary.stale} obsoletas; ${summary.conflicts} conflitos; ${summary.missing} origens nativas ausentes; ${summary.legacy} documentos antigos; ${summary.registries} indices derivados; ${summary.gitignores} gitignores; ${summary.localIgnores} exclusoes locais.`);
|
|
585
636
|
if (localStatus.status === 'no-git') output('Git ausente: apos git init, execute a sincronizacao para proteger os arquivos locais.');
|
|
586
637
|
const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
|
|
587
638
|
if (check) return { ...summary, ownedOutputs, managedBlocks, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
|
|
639
|
+
if (affectedIndex.conflicts.length) fail(`Indice Git contem alteracoes proprias; arquivos preservados: ${sorted(affectedIndex.conflicts).join(', ')}`);
|
|
640
|
+
if (protectedConflicts.size) fail(`Conteudo proprio preservado; resolva a colisao antes de sincronizar: ${sorted(protectedConflicts).join(', ')}`);
|
|
588
641
|
if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
|
|
589
642
|
if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
|
|
590
643
|
if (localPlan) assertLocalIgnoreUnchanged(localPlan);
|
|
@@ -594,7 +647,6 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
594
647
|
while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
|
|
595
648
|
for (const directory of missing.reverse()) {
|
|
596
649
|
mkdirSync(safe(directory)); createdDirectories.add(directory);
|
|
597
|
-
if (restoring) restoredDirectories.add(directory);
|
|
598
650
|
}
|
|
599
651
|
}
|
|
600
652
|
function mutate(value, action, text) {
|
|
@@ -649,40 +701,27 @@ export function synchronize({ root = projectRoot, check = false, force = false,
|
|
|
649
701
|
}
|
|
650
702
|
try {
|
|
651
703
|
if (localPlan) assertLocalIgnoreUnchanged(localPlan);
|
|
704
|
+
if (Object.keys(affectedHashes).length) {
|
|
705
|
+
if (!assertGitReadableForChanges() && hadGit) fail('Git deixou de estar disponivel antes da sincronizacao; arquivos preservados.');
|
|
706
|
+
const current = planUntrack(root, affectedHashes);
|
|
707
|
+
if (current.conflicts.length || current.index !== affectedIndex.index) fail('Indice Git mudou antes da sincronizacao; conteudo preservado.');
|
|
708
|
+
}
|
|
652
709
|
for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
|
|
653
710
|
for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
|
|
654
711
|
for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
|
|
655
712
|
for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
|
|
656
713
|
for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
|
|
657
714
|
if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
|
|
658
|
-
//
|
|
659
|
-
for (const value of removals) {
|
|
660
|
-
let parent = posix.dirname(value);
|
|
661
|
-
const boundary = value.split('/').slice(0, 2).join('/');
|
|
662
|
-
while (parent.startsWith(boundary + '/') || parent === boundary) {
|
|
663
|
-
const info = stat(safe(parent));
|
|
664
|
-
if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
|
|
665
|
-
rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
715
|
+
// Directory ownership is not recorded: keep even empty parent directories.
|
|
668
716
|
if (localPlan) applyLocalIgnore(localPlan);
|
|
669
717
|
} catch (error) {
|
|
670
718
|
const failures = [];
|
|
671
|
-
restoring = true;
|
|
672
719
|
for (const value of [...touched].reverse()) {
|
|
673
720
|
try {
|
|
674
721
|
if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
|
|
675
722
|
restore(value, originals.get(value));
|
|
676
723
|
} catch (failure) { failures.push(failure); }
|
|
677
724
|
}
|
|
678
|
-
for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
|
|
679
|
-
try {
|
|
680
|
-
if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
|
|
681
|
-
const info = stat(safe(directory));
|
|
682
|
-
if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
|
|
683
|
-
chmodSync(safe(directory), mode);
|
|
684
|
-
} catch (failure) { failures.push(failure); }
|
|
685
|
-
}
|
|
686
725
|
for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
|
|
687
726
|
try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
|
|
688
727
|
catch (failure) { failures.push(failure); }
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { synchronize } from '../SCRIPT.mjs';
|
|
9
|
+
import { indexContentMatches } from '../../lib/gitignore.mjs';
|
|
10
|
+
|
|
11
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
12
|
+
const original = '.pbai4devs/skills/projeto/criar-skill/SKILL.md';
|
|
13
|
+
const pointer = '.claude/skills/criar-skill/SKILL.md';
|
|
14
|
+
const file = (root, relative) => join(root, relative);
|
|
15
|
+
const put = (root, relative, content) => { mkdirSync(dirname(file(root, relative)), { recursive: true }); writeFileSync(file(root, relative), content); };
|
|
16
|
+
function git(root, ...args) {
|
|
17
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
18
|
+
assert.equal(result.status, 0, `${args.join(' ')}: ${result.stderr}`);
|
|
19
|
+
return result.stdout;
|
|
20
|
+
}
|
|
21
|
+
function fixture(t, withGit = true) {
|
|
22
|
+
mkdirSync(join(repository, '.agent-tests'), { recursive: true });
|
|
23
|
+
const root = mkdtempSync(join(withGit ? join(repository, '.agent-tests') : tmpdir(), 'sync-preservation-'));
|
|
24
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
25
|
+
if (withGit) git(root, 'init', '--quiet');
|
|
26
|
+
populate(root);
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
function populate(root) {
|
|
30
|
+
put(root, '.gitignore', '# Human ignore\nnode_modules/\n');
|
|
31
|
+
put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
|
|
32
|
+
put(root, '.pbai4devs/templates/gitignore/clients.json', '{}\n');
|
|
33
|
+
put(root, '.pbai4devs/templates/gitignore/shared.ignore', '# Shared\n/tools/*/.local/\n');
|
|
34
|
+
put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
|
|
35
|
+
put(root, original, '---\nname: criar-skill\ndescription: Criar recurso.\n---\n\n# Criar skill\n');
|
|
36
|
+
assert.equal(run(root).exitCode, 0);
|
|
37
|
+
assert.ok(existsSync(file(root, pointer)));
|
|
38
|
+
}
|
|
39
|
+
function run(root, options = {}) {
|
|
40
|
+
return synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options });
|
|
41
|
+
}
|
|
42
|
+
function withoutGit(t, callback) {
|
|
43
|
+
const emptyPath = mkdtempSync(join(tmpdir(), 'sync-empty-path-'));
|
|
44
|
+
t.after(() => rmSync(emptyPath, { recursive: true, force: true }));
|
|
45
|
+
const keys = Object.keys(process.env).filter(key => key.toLowerCase() === 'path');
|
|
46
|
+
const previous = new Map(keys.map(key => [key, process.env[key]]));
|
|
47
|
+
if (!keys.length) keys.push('PATH');
|
|
48
|
+
try {
|
|
49
|
+
for (const key of keys) process.env[key] = emptyPath;
|
|
50
|
+
return callback();
|
|
51
|
+
} finally {
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
if (previous.has(key)) process.env[key] = previous.get(key);
|
|
54
|
+
else delete process.env[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [withGit, force] of [[true, false], [true, true], [false, false], [false, true]]) {
|
|
60
|
+
test(`remoção obsoleta preserva pasta vazia humana (git=${withGit}, force=${force})`, t => {
|
|
61
|
+
const root = fixture(t, withGit), human = '.claude/skills/criar-skill/Equipe-Vazia';
|
|
62
|
+
mkdirSync(file(root, human));
|
|
63
|
+
const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
|
|
64
|
+
const index = withGit ? git(root, 'ls-files', '--stage', '-z') : null;
|
|
65
|
+
const ignore = readFileSync(file(root, '.gitignore'));
|
|
66
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
67
|
+
const check = run(root, { check: true, force });
|
|
68
|
+
assert.notEqual(check.exitCode, 0);
|
|
69
|
+
assert.ok(existsSync(file(root, pointer)));
|
|
70
|
+
assert.ok(existsSync(file(root, human)));
|
|
71
|
+
assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
|
|
72
|
+
run(root, { force });
|
|
73
|
+
assert.equal(existsSync(file(root, pointer)), false, 'apontamento obsoleto comprovado é removido');
|
|
74
|
+
assert.ok(existsSync(file(root, human)), 'pasta vazia própria permanece');
|
|
75
|
+
assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
|
|
76
|
+
if (withGit) assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const force of [false, true]) {
|
|
81
|
+
test(`índice com conteúdo humano divergente bloqueia remoção (force=${force})`, t => {
|
|
82
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
83
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
|
|
84
|
+
git(root, 'add', '-f', '--', pointer);
|
|
85
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
86
|
+
put(root, pointer, generated);
|
|
87
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
88
|
+
const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
|
|
89
|
+
const check = run(root, { check: true, force });
|
|
90
|
+
assert.notEqual(check.exitCode, 0);
|
|
91
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
92
|
+
assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
|
|
93
|
+
assert.throws(() => run(root, { force }), /conflitos|indice|índice/i);
|
|
94
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
95
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const force of [false, true]) {
|
|
100
|
+
test(`índice divergente impede atualização de apontamento ativo (force=${force})`, t => {
|
|
101
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
102
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
|
|
103
|
+
git(root, 'add', '-f', '--', pointer);
|
|
104
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
105
|
+
put(root, pointer, generated);
|
|
106
|
+
put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
|
|
107
|
+
const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
|
|
108
|
+
assert.notEqual(run(root, { check: true, force }).exitCode, 0);
|
|
109
|
+
assert.throws(() => run(root, { force }), /indice/i);
|
|
110
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
111
|
+
assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
|
|
112
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const flag of ['--assume-unchanged', '--skip-worktree']) {
|
|
117
|
+
test(`índice divergente continua protegido com ${flag}`, t => {
|
|
118
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
119
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
|
|
120
|
+
git(root, 'add', '-f', '--', pointer);
|
|
121
|
+
put(root, pointer, generated);
|
|
122
|
+
git(root, 'update-index', flag, '--', pointer);
|
|
123
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
124
|
+
const flags = git(root, 'ls-files', '-v', '--', pointer);
|
|
125
|
+
put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
|
|
126
|
+
assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
|
|
127
|
+
assert.throws(() => run(root, { force: true }), /indice/i);
|
|
128
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
129
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
130
|
+
assert.equal(git(root, 'ls-files', '-v', '--', pointer), flags);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('índice com alias de caixa bloqueia alteração conservadoramente em qualquer sistema', t => {
|
|
135
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
136
|
+
const alias = pointer.replace('/skills/', '/SKILLS/');
|
|
137
|
+
put(root, alias, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
|
|
138
|
+
git(root, 'add', '-f', '--', alias);
|
|
139
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
140
|
+
put(root, pointer, generated);
|
|
141
|
+
put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
|
|
142
|
+
const aliasBytes = readFileSync(file(root, alias));
|
|
143
|
+
const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
|
|
144
|
+
const ignore = readFileSync(file(root, '.gitignore'));
|
|
145
|
+
const preserved = () => {
|
|
146
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
147
|
+
assert.deepEqual(readFileSync(file(root, alias)), aliasBytes);
|
|
148
|
+
assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
|
|
149
|
+
assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
|
|
150
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
151
|
+
};
|
|
152
|
+
let check, error;
|
|
153
|
+
try { check = run(root, { check: true, force: true }); } catch (failure) { error = failure; }
|
|
154
|
+
if (error) assert.match(error.message, /maiusculas\/minusculas/i);
|
|
155
|
+
else assert.notEqual(check.exitCode, 0);
|
|
156
|
+
preserved();
|
|
157
|
+
assert.throws(() => run(root, { force: true }), /indice|maiusculas\/minusculas/i);
|
|
158
|
+
preserved();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('comparação de índice entende CRLF autorizado e recusa filtros, binários e divergência', t => {
|
|
162
|
+
const root = fixture(t), path = 'arquivo.txt';
|
|
163
|
+
const crlf = Buffer.from('texto\r\noutra linha\r\n'), lf = Buffer.from('texto\noutra linha\n');
|
|
164
|
+
git(root, 'config', 'core.autocrlf', 'false');
|
|
165
|
+
git(root, 'config', 'core.attributesfile', file(root, 'atributos-ausentes'));
|
|
166
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), false);
|
|
167
|
+
git(root, 'config', 'core.autocrlf', 'true');
|
|
168
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), true);
|
|
169
|
+
assert.equal(indexContentMatches(root, path, crlf, Buffer.from('texto humano\noutra linha\n')), false);
|
|
170
|
+
assert.equal(indexContentMatches(root, path, Buffer.from([0, 13, 10]), Buffer.from([0, 10])), false);
|
|
171
|
+
assert.equal(indexContentMatches(root, path, Buffer.from('um\r\ndois\r'), Buffer.from('um\ndois\r')), false);
|
|
172
|
+
assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('texto\n')), false);
|
|
173
|
+
assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('\ufefftexto\n')), true);
|
|
174
|
+
put(root, '.gitattributes', '*.txt -text\n');
|
|
175
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), false);
|
|
176
|
+
put(root, '.gitattributes', '*.txt text filter=nao-executar\n');
|
|
177
|
+
git(root, 'config', 'filter.nao-executar.clean', 'exit 1');
|
|
178
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), false);
|
|
179
|
+
put(root, '.gitattributes', '*.txt text working-tree-encoding=UTF-16\n');
|
|
180
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), false);
|
|
181
|
+
put(root, '.gitattributes', '*.txt text ident\n');
|
|
182
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), false);
|
|
183
|
+
put(root, '.gitattributes', '*.txt text eol=crlf\n');
|
|
184
|
+
git(root, 'config', 'core.autocrlf', 'false');
|
|
185
|
+
assert.equal(indexContentMatches(root, path, crlf, lf), true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('sync atualiza apontamento staged equivalente em repositório com autocrlf', t => {
|
|
189
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer), 'utf8').replaceAll('\n', '\r\n');
|
|
190
|
+
git(root, 'config', 'core.autocrlf', 'true');
|
|
191
|
+
put(root, pointer, generated);
|
|
192
|
+
git(root, 'add', '-f', '--', pointer);
|
|
193
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
194
|
+
put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
|
|
195
|
+
assert.equal(run(root, { force: true }).exitCode, 0);
|
|
196
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
for (const target of ['.pbai4devs/state/derived.json', '.claude/.gitignore']) {
|
|
200
|
+
test(`índice divergente impede atualização de ${target}`, t => {
|
|
201
|
+
const root = fixture(t), generated = readFileSync(file(root, target));
|
|
202
|
+
put(root, target, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
|
|
203
|
+
git(root, 'add', '-f', '--', target);
|
|
204
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
205
|
+
put(root, target, generated);
|
|
206
|
+
if (target.endsWith('derived.json')) {
|
|
207
|
+
put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
|
|
208
|
+
} else {
|
|
209
|
+
put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache atualizado\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
|
|
210
|
+
}
|
|
211
|
+
assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
|
|
212
|
+
assert.throws(() => run(root, { force: true }), /indice/i);
|
|
213
|
+
assert.deepEqual(readFileSync(file(root, target)), generated);
|
|
214
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const options of [{ check: true }, { force: true }]) {
|
|
219
|
+
test(`Git indisponível bloqueia limpeza em repositório (check=${Boolean(options.check)}, force=${Boolean(options.force)})`, t => {
|
|
220
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
221
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
|
|
222
|
+
git(root, 'add', '-f', '--', pointer);
|
|
223
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
224
|
+
put(root, pointer, generated);
|
|
225
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
226
|
+
const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
|
|
227
|
+
withoutGit(t, () => assert.throws(() => run(root, options), /consultar o Git.*preservar/i));
|
|
228
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
229
|
+
assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
|
|
230
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
test('projeto realmente sem Git pode limpar apontamento próprio sem executável Git', t => {
|
|
235
|
+
const root = fixture(t, false), human = '.claude/skills/criar-skill/equipe-vazia';
|
|
236
|
+
mkdirSync(file(root, human));
|
|
237
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
238
|
+
withoutGit(t, () => assert.equal(run(root, { force: true }).exitCode, 0));
|
|
239
|
+
assert.equal(existsSync(file(root, pointer)), false);
|
|
240
|
+
assert.ok(existsSync(file(root, human)));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('preserva diretório preexistente sem prova de propriedade após remover único apontamento', t => {
|
|
244
|
+
const root = fixture(t), directory = dirname(file(root, pointer));
|
|
245
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
246
|
+
assert.equal(run(root, { force: true }).exitCode, 0);
|
|
247
|
+
assert.equal(existsSync(file(root, pointer)), false);
|
|
248
|
+
assert.ok(existsSync(directory));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('alteração concorrente no índice bloqueia limpeza antes de qualquer exclusão', t => {
|
|
252
|
+
const root = fixture(t), generated = readFileSync(file(root, pointer));
|
|
253
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
254
|
+
let staged;
|
|
255
|
+
assert.throws(() => synchronize({ root, localIgnores: false, generateEntrypoints: false, force: true,
|
|
256
|
+
output() {
|
|
257
|
+
if (staged !== undefined) return;
|
|
258
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
|
|
259
|
+
git(root, 'add', '-f', '--', pointer);
|
|
260
|
+
staged = git(root, 'ls-files', '--stage', '-z');
|
|
261
|
+
put(root, pointer, generated);
|
|
262
|
+
},
|
|
263
|
+
}), /indice git mudou/i);
|
|
264
|
+
assert.notEqual(staged, undefined);
|
|
265
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
266
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), staged);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('índice divergente é protegido quando o projeto fica em subpasta do repositório Git', t => {
|
|
270
|
+
const outer = mkdtempSync(join(tmpdir(), 'sync-parent-git-'));
|
|
271
|
+
t.after(() => rmSync(outer, { recursive: true, force: true }));
|
|
272
|
+
git(outer, 'init', '--quiet');
|
|
273
|
+
const root = join(outer, 'projeto'); mkdirSync(root);
|
|
274
|
+
populate(root);
|
|
275
|
+
const generated = readFileSync(file(root, pointer));
|
|
276
|
+
put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
|
|
277
|
+
git(root, 'add', '-f', '--', pointer);
|
|
278
|
+
const index = git(outer, 'ls-files', '--stage', '-z');
|
|
279
|
+
put(root, pointer, generated);
|
|
280
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
281
|
+
assert.throws(() => run(root, { force: true }), /indice/i);
|
|
282
|
+
assert.deepEqual(readFileSync(file(root, pointer)), generated);
|
|
283
|
+
assert.equal(git(outer, 'ls-files', '--stage', '-z'), index);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('conteúdo humano na pasta e apontamento gerenciado editado sobrevivem à limpeza forçada', t => {
|
|
287
|
+
const root = fixture(t), human = '.claude/skills/criar-skill/notas-da-equipe.md';
|
|
288
|
+
const notes = '# Referência própria\n', edited = Buffer.concat([readFileSync(file(root, pointer)), Buffer.from('\nNota própria.\n')]);
|
|
289
|
+
put(root, human, notes);
|
|
290
|
+
put(root, pointer, edited);
|
|
291
|
+
rmSync(dirname(file(root, original)), { recursive: true });
|
|
292
|
+
run(root, { force: true });
|
|
293
|
+
assert.equal(readFileSync(file(root, human), 'utf8'), notes);
|
|
294
|
+
assert.deepEqual(readFileSync(file(root, pointer)), edited);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('colisão com arquivo humano ancestral não é removida por --force', t => {
|
|
298
|
+
const root = fixture(t), ancestor = '.claude/skills/equipe-custom';
|
|
299
|
+
const human = 'Arquivo da equipe; não substituir.\n';
|
|
300
|
+
put(root, ancestor, human);
|
|
301
|
+
put(root, '.pbai4devs/skills/projeto/equipe-custom/SKILL.md', '---\nname: equipe-custom\ndescription: Exemplo.\n---\n\n# Equipe\n');
|
|
302
|
+
const index = git(root, 'ls-files', '--stage', '-z');
|
|
303
|
+
try { run(root, { force: true }); } catch (error) { assert.match(error.message, /conflito|gerenciado|arquivo|diretor/i); }
|
|
304
|
+
assert.equal(readFileSync(file(root, ancestor), 'utf8'), human);
|
|
305
|
+
assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
|
|
306
|
+
});
|
|
@@ -13,6 +13,7 @@ import './LOCAL_IGNORE.mjs';
|
|
|
13
13
|
import './HUMAN_OWNERSHIP.mjs';
|
|
14
14
|
import './COEXISTENCE.mjs';
|
|
15
15
|
import './MANAGED_CONTENT.mjs';
|
|
16
|
+
import './PRESERVATION.mjs';
|
|
16
17
|
import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
|
|
17
18
|
import { stateDirectory } from '../../lib/state-path.mjs';
|
|
18
19
|
|
|
@@ -140,7 +141,12 @@ test('sincroniza somente principais, preserva metadados e usa referências UTF-8
|
|
|
140
141
|
assert.match(read(root, '.kimi-code/agents/revisor.md'), /tools: \[Read, Grep, Glob\]/);
|
|
141
142
|
assert.ok(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')));
|
|
142
143
|
for (const client of ['.claude', '.opencode', '.github', '.codex', '.cursor', '.kimi-code', '.vscode', '.agents', '.agent', '.gemini']) {
|
|
143
|
-
|
|
144
|
+
for (const kind of ['templates', 'scripts', 'tools', 'external-skills', 'state', 'estado', 'tests', 'docs', 'adapters', 'extra', 'README.md', 'INSTRUCTIONS.md']) {
|
|
145
|
+
if (client === '.claude' && ['templates', 'scripts', 'tools', 'external-skills', 'state', 'tests', 'docs', 'adapters'].includes(kind)) {
|
|
146
|
+
assert.equal(existsSync(join(root, client, kind, 'demo/REFERENCE.md')), false);
|
|
147
|
+
assert.ok(existsSync(join(root, client, kind)), 'diretório sem prova de propriedade permanece');
|
|
148
|
+
} else assert.equal(existsSync(join(root, client, kind)), false, `${client}/${kind}`);
|
|
149
|
+
}
|
|
144
150
|
}
|
|
145
151
|
assert.deepEqual(JSON.parse(read(root, '.pbai4devs/agents.json')).entries, [{ path: '.pbai4devs/agents/revisor/AGENT.md' }]);
|
|
146
152
|
assert.ok(JSON.parse(read(root, '.pbai4devs/skills.json')).entries.some(entry => entry.path === '.pbai4devs/skills/grupo com espaço/outro'));
|
|
@@ -441,7 +447,8 @@ test('atualiza fontes, adiciona clientes, remove recursos e migra agrupado para
|
|
|
441
447
|
rmSync(join(root, '.pbai4devs/rules/example/RULE.md'));
|
|
442
448
|
write(root, '.pbai4devs/espelhos.conf', read(root, '.pbai4devs/espelhos.conf').replace('agente|.claude\n', 'agente|.claude|plano\n'));
|
|
443
449
|
run(root);
|
|
444
|
-
assert.equal(existsSync(join(root, '.claude/skills/projeto')), false);
|
|
450
|
+
assert.equal(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')), false);
|
|
451
|
+
assert.ok(existsSync(join(root, '.claude/skills/projeto')));
|
|
445
452
|
assert.ok(existsSync(join(root, '.claude/skills/demo/SKILL.md')));
|
|
446
453
|
assert.equal(existsSync(join(root, '.codex/rules/example.md')), false);
|
|
447
454
|
assert.deepEqual(JSON.parse(read(root, '.pbai4devs/rules.json')).entries, []);
|
|
@@ -882,7 +889,8 @@ test('manifestos Antigravity acompanham skills, regras e agentes agrupados', t =
|
|
|
882
889
|
rmSync(join(root, '.pbai4devs/skills/projeto/demo'), { recursive: true });
|
|
883
890
|
run(root, { generateEntrypoints: true });
|
|
884
891
|
assert.deepEqual(JSON.parse(read(root, '.agents/skills.json')).entries, [{ path: '.agents/skills/projeto/new-skill' }]);
|
|
885
|
-
assert.equal(existsSync(join(root, '.agents/skills/projeto/demo')), false);
|
|
892
|
+
assert.equal(existsSync(join(root, '.agents/skills/projeto/demo/SKILL.md')), false);
|
|
893
|
+
assert.ok(existsSync(join(root, '.agents/skills/projeto/demo')));
|
|
886
894
|
assertCheckReadonly(root, { generateEntrypoints: true }, 0);
|
|
887
895
|
});
|
|
888
896
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ description: Alterações verificáveis por versão do AgentDevKit.
|
|
|
4
4
|
|
|
5
5
|
# Changelog
|
|
6
6
|
|
|
7
|
+
## 0.0.18 — 2026-11-13
|
|
8
|
+
|
|
9
|
+
- Sincronização preserva recursos próprios, colisões e alterações humanas.
|
|
10
|
+
- Limpeza de recursos obsoletos exige comprovação de propriedade.
|
|
11
|
+
- Comandos e efeitos sobre arquivos, índice e backups documentados.
|
|
12
|
+
|
|
7
13
|
## 0.0.17 — 2026-11-13
|
|
8
14
|
|
|
9
15
|
- A pasta compartilhada de estado passa a se chamar `.pbai4devs/state`.
|
package/docs/guides/cli.md
CHANGED
|
@@ -22,17 +22,17 @@ atendido; lá os comandos de manutenção usam o pacote por `npx`.
|
|
|
22
22
|
|
|
23
23
|
| Comando | Resultado |
|
|
24
24
|
| --- | --- |
|
|
25
|
-
| `init [pasta]` | Instala ou reaplica
|
|
26
|
-
| `update` |
|
|
27
|
-
| `sync` |
|
|
28
|
-
| `sync --check` | Confere
|
|
29
|
-
| `doctor` | Diagnostica
|
|
30
|
-
| `next` |
|
|
25
|
+
| `init [pasta]` | Instala o kit no projeto ou reaplica a instalação. |
|
|
26
|
+
| `update` | Atualiza a fonte usando a versão do pacote executado. |
|
|
27
|
+
| `sync` | Atualiza catálogos e apontamentos a partir da fonte local. |
|
|
28
|
+
| `sync --check` | Confere sincronização sem alterar o projeto. |
|
|
29
|
+
| `doctor` | Diagnostica o kit e indica divergências. |
|
|
30
|
+
| `next` | Mostra a próxima etapa, sem iniciá-la. |
|
|
31
31
|
| `lang [idioma]` | Consulta ou altera o idioma: pt, en ou es. |
|
|
32
|
-
| `agents` |
|
|
33
|
-
| `agents codex,claude` | Altera a seleção
|
|
34
|
-
| `agents all` |
|
|
35
|
-
| `tools <ação>` |
|
|
32
|
+
| `agents` | Mostra os clientes disponíveis e a seleção salva. |
|
|
33
|
+
| `agents codex,claude` | Altera a seleção e os apontamentos necessários. |
|
|
34
|
+
| `agents all` | Seleciona todos os clientes compatíveis. |
|
|
35
|
+
| `tools <ação>` | Consulta, instala, atualiza ou executa ferramentas. |
|
|
36
36
|
| `--help`, `--version` | Ajuda ou versão do pacote. |
|
|
37
37
|
|
|
38
38
|
Sem comando, a CLI executa `init`, que pode criar arquivos. `language` é alias
|
|
@@ -48,16 +48,47 @@ de `lang`; `clients`, de `agents`. O idioma não traduz documentos existentes.
|
|
|
48
48
|
| `--clients <lista>` | Alias de `--agents`. |
|
|
49
49
|
| `--yes`, `-y` | Evita perguntas iniciais; não força conflitos. |
|
|
50
50
|
| `--dry-run` | Exibe o plano sem aplicar alterações. |
|
|
51
|
-
| `--force` |
|
|
51
|
+
| `--force` | Solicita reparos gerenciados permitidos, com backup. |
|
|
52
52
|
| `--json` | Produz uma resposta JSON para automação. |
|
|
53
53
|
|
|
54
54
|
O primeiro uso interativo reúne idioma e clientes. Sem interação, valem as
|
|
55
55
|
opções explícitas, a escolha local salva e, por último, `all`. Confira os
|
|
56
56
|
[IDs e dependências](../../.pbai4devs/COMPATIBILIDADE.md).
|
|
57
57
|
|
|
58
|
-
`update`
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
`update` usa a versão executada; para escolher outra, fixe-a no nome do
|
|
59
|
+
pacote. `sync` usa o que já está em `.pbai4devs`: ele não baixa outra versão.
|
|
60
|
+
Ambos conciliam recursos e removem apontamentos obsoletos quando a propriedade
|
|
61
|
+
do kit é comprovada. Recursos e configurações próprios permanecem no projeto.
|
|
62
|
+
Diretórios antigos sem comprovação de propriedade podem permanecer vazios.
|
|
63
|
+
Ao mudar a seleção, pastas compartilhadas continuam quando outro cliente
|
|
64
|
+
precisa delas; arquivos adicionais da equipe devem ser preservados.
|
|
65
|
+
|
|
66
|
+
Antes de aplicar em projeto existente, confira o plano e depois o diagnóstico:
|
|
67
|
+
|
|
68
|
+
```console
|
|
69
|
+
npx @pathbit/agentdevkit update --dry-run --json
|
|
70
|
+
npx @pathbit/agentdevkit update
|
|
71
|
+
npx @pathbit/agentdevkit doctor
|
|
72
|
+
npx @pathbit/agentdevkit sync --check
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `init --dry-run --json`, `sync --dry-run --json` ou
|
|
76
|
+
`agents codex,claude --dry-run --json` para as outras operações. Examine
|
|
77
|
+
`plan`, `conflicts`, `preserved`, `untrackedPaths` e `localIgnore` antes de
|
|
78
|
+
aplicar. Conflitos sem solução interrompem a operação; não presuma que uma
|
|
79
|
+
colisão de nomes torna um arquivo propriedade do kit.
|
|
80
|
+
|
|
81
|
+
O sincronizador independente também pode ser executado pelo
|
|
82
|
+
[script local](../../.pbai4devs/scripts/sincronizar-agentes/README.md). Seu
|
|
83
|
+
`--check` apenas confere e `--force` é explícito na linha de comando. O
|
|
84
|
+
comando agêntico [sincronizar](../../.pbai4devs/commands/sincronizar/COMMAND.md)
|
|
85
|
+
orienta executar o script com `--force`; ele não é o mesmo que `sync` da CLI.
|
|
86
|
+
|
|
87
|
+
No Git, o kit preserva o `.gitignore` da raiz e pode atualizar
|
|
88
|
+
`.git/info/exclude`. Se o plano indicar `untrackedPaths`, a operação pode
|
|
89
|
+
retirar do índice saídas comprovadamente geradas, mantendo seus arquivos no
|
|
90
|
+
disco. Confira a lista: `--dry-run` e `sync --check` não alteram o índice.
|
|
91
|
+
Backups locais ficam em `.agent-tests/` e não substituem o histórico Git.
|
|
61
92
|
|
|
62
93
|
## Ferramentas
|
|
63
94
|
|
|
@@ -80,7 +111,7 @@ do separador. `--help` e `--version` do kit não exigem um projeto instalado.
|
|
|
80
111
|
As respostas incluem `ok`, `command`, `version`, `language`, `target` e
|
|
81
112
|
`next`. Conforme a operação, há seleção, plano, conflitos, backup e erros.
|
|
82
113
|
Leia os campos estruturados, não dependa da tradução da mensagem.
|
|
83
|
-
`preserved` lista caminhos
|
|
114
|
+
`preserved` lista caminhos mantidos pelo projeto e o motivo.
|
|
84
115
|
Um complemento pode ser gerenciado por bloco sem tornar o arquivo propriedade
|
|
85
116
|
do kit. Conteúdo humano não é substituído por `--force`.
|
|
86
117
|
|
|
@@ -111,7 +111,7 @@ usar `git add`. No GitHub Packages, use o prefixo de registry da instalação.
|
|
|
111
111
|
|
|
112
112
|
O bloco local também protege `.agent-tests`, `tmp`, `temp`, `.playwright*`
|
|
113
113
|
e relatórios de testes, inclusive com letras maiúsculas e em subpastas.
|
|
114
|
-
O conteúdo do `.gitignore` existente
|
|
114
|
+
O conteúdo do `.gitignore` existente na raiz é preservado.
|
|
115
115
|
Uma exceção que reabra arquivo privado ou artefato é informada como conflito;
|
|
116
116
|
o projeto decide como ajustar suas próprias regras.
|
|
117
117
|
Negativas com curingas também podem ser recusadas preventivamente, mesmo antes
|
|
@@ -121,6 +121,9 @@ a validação fica restrita ao projeto e aos ignores de seus ancestrais.
|
|
|
121
121
|
|
|
122
122
|
Arquivos já rastreados exigem migração do índice: apenas saídas identificadas
|
|
123
123
|
do kit podem deixar de ser rastreadas, preservando seus bytes no disco.
|
|
124
|
+
O plano informa esses caminhos em `untrackedPaths` antes de aplicar; examine-os
|
|
125
|
+
e preserve alterações preparadas da equipe. `--dry-run` e `sync --check` não
|
|
126
|
+
alteram o índice.
|
|
124
127
|
Artefatos já rastreados são informados por `doctor` e `sync --check` para
|
|
125
128
|
revisão; não são removidos automaticamente do índice.
|
|
126
129
|
Revise o plano e os conflitos em um projeto existente; use `--dry-run` antes
|
|
@@ -133,13 +136,20 @@ continua válida; `agents` consulta e `agents codex,claude` altera a seleção.
|
|
|
133
136
|
`agents all` habilita todos. As pastas compartilhadas permanecem quando
|
|
134
137
|
necessárias para outro cliente, como `.agents` para Codex e Antigravity.
|
|
135
138
|
|
|
136
|
-
Confira
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
Confira `update --dry-run --json` antes de atualizar um projeto existente.
|
|
140
|
+
Revise arquivos criados, atualizados e removidos, conflitos, `preserved` e
|
|
141
|
+
eventuais alterações do índice. `sync` usa as fontes já instaladas e não baixa
|
|
142
|
+
uma nova versão. Consulte a [referência da CLI](cli.md) para todos os comandos.
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
Skills e outros recursos próprios podem coexistir com os do kit. Um nome
|
|
145
|
+
igual não comprova propriedade: conteúdo humano e arquivos adicionais não
|
|
146
|
+
devem ser descartados para reconstruir apontamentos. Remoções automáticas
|
|
147
|
+
ficam restritas a arquivos obsoletos cuja propriedade possa ser comprovada.
|
|
148
|
+
`--force` não autoriza substituir conteúdo próprio; backups ficam em
|
|
149
|
+
`.agent-tests/` e devem ser revisados quando houver reparo gerenciado.
|
|
150
|
+
|
|
151
|
+
Em entradas como `AGENTS.md`, `claude.md` e `GEMINI.md`, e em recursos nativos
|
|
152
|
+
compatíveis, o kit pode acrescentar apenas uma referência delimitada:
|
|
143
153
|
|
|
144
154
|
```markdown
|
|
145
155
|
<!-- BEGIN AGENTDEVKIT -->
|
|
@@ -148,7 +158,9 @@ Leia e siga [origem](caminho/para/.pbai4devs/recurso.md).
|
|
|
148
158
|
```
|
|
149
159
|
|
|
150
160
|
O texto e o frontmatter humanos permanecem; a referência usa o caminho real
|
|
151
|
-
do recurso.
|
|
161
|
+
do recurso. Um arquivo que não possa receber o bloco é preservado e informado,
|
|
162
|
+
sem prometer a adição do apontamento. Repetir a operação não duplica o bloco.
|
|
163
|
+
Edite suas instruções fora
|
|
152
164
|
dele: atualizações alteram somente a contribuição do kit. Nomes de entradas
|
|
153
165
|
com caixa diferente são reaproveitados quando há uma única opção.
|
|
154
166
|
Comandos Gemini mantêm a referência dentro do valor `prompt` em TOML válido.
|
package/docs/specs/README.md
CHANGED
|
@@ -21,6 +21,7 @@ description: Incrementos verificáveis do AgentDevKit.
|
|
|
21
21
|
especialistas, colaboração e provas.
|
|
22
22
|
- [Confiabilidade da pipeline](ci-resilience/SPEC.md): testes e releases.
|
|
23
23
|
- [Pasta de estado](state-directory/SPEC.md): migração segura para `state`.
|
|
24
|
+
- [Sincronização segura](safe-synchronization/SPEC.md): preservar recursos próprios.
|
|
24
25
|
- [Integração sem substituição][additive]: acréscimos em arquivos existentes.
|
|
25
26
|
- [Compatibilidade com projetos reais][oss]: projetos públicos e exemplos reproduzíveis.
|
|
26
27
|
- [Ignore do projeto](project-ignore/SPEC.md): preservar o arquivo da raiz.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Sincronização segura com recursos e alterações próprios do projeto.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Sincronização segura
|
|
6
|
+
|
|
7
|
+
Projetos podem ter arquivos próprios e recursos adicionais aos do pacote.
|
|
8
|
+
Os comandos devem preservar esse conteúdo ao instalar, atualizar e sincronizar.
|
|
9
|
+
|
|
10
|
+
## Critérios de aceite
|
|
11
|
+
|
|
12
|
+
- AC-001: Recursos humanos ou modificados no hub e nos clientes são
|
|
13
|
+
preservados, inclusive em colisões e com `--force`.
|
|
14
|
+
- AC-002: A limpeza remove somente saídas obsoletas com propriedade comprovada;
|
|
15
|
+
pastas e arquivos adicionais da equipe não são removidos.
|
|
16
|
+
- AC-003: Um conteúdo divergente preparado no índice não é descartado pela
|
|
17
|
+
sincronização; `.gitignore` da raiz e dados humanos são preservados.
|
|
18
|
+
- AC-004: `init`, `update`, `sync`, consultas e opções estão documentados com
|
|
19
|
+
seus efeitos sobre arquivos, backups, seleção de clientes e Git.
|
|
20
|
+
- AC-005: Testes exercitam instalações existentes, obsolescência, colisões,
|
|
21
|
+
execução repetida, Git e falhas; a CI contempla Linux, Windows e macOS.
|
package/package.json
CHANGED
package/src/i18n.mjs
CHANGED
|
@@ -42,6 +42,16 @@ const messages = {
|
|
|
42
42
|
};
|
|
43
43
|
export const text = (language, key) => messages[language || 'pt'][key] || key;
|
|
44
44
|
export function explainError(language, code) {
|
|
45
|
+
if (code === 'E_STAGED') return {
|
|
46
|
+
pt: 'Há alterações preparadas diferentes dos arquivos locais; revise-as antes de atualizar.',
|
|
47
|
+
en: 'Staged changes differ from the working files; review them before updating.',
|
|
48
|
+
es: 'Hay cambios preparados distintos de los archivos locales; revíselos antes de actualizar.',
|
|
49
|
+
}[language || 'pt'];
|
|
50
|
+
if (code === 'E_GIT') return {
|
|
51
|
+
pt: 'Não foi possível verificar o índice Git com segurança; nenhum arquivo foi alterado.',
|
|
52
|
+
en: 'The Git index could not be checked safely; no files were changed.',
|
|
53
|
+
es: 'No se pudo verificar el índice Git de forma segura; no se modificaron archivos.',
|
|
54
|
+
}[language || 'pt'];
|
|
45
55
|
if (code === 'E_CONFIG_PARSE') return {
|
|
46
56
|
pt: 'Não foi possível analisar a configuração legada com segurança; revise o frontmatter e as aspas antes de executar update.',
|
|
47
57
|
en: 'The legacy configuration could not be safely parsed; review its frontmatter and quoted values before running update.',
|
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, planUntrack, applyUntrack, restoreUntrack } from '../.pbai4devs/scripts/lib/gitignore.mjs';
|
|
7
|
+
import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack, 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,13 +649,53 @@ 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
701
|
const hashes = {};
|
|
@@ -539,6 +719,24 @@ export function applyPlan(plan) {
|
|
|
539
719
|
if (!plan.canApply) fail('E_CONFLICT', text(plan.language, 'conflict'));
|
|
540
720
|
assertLocalIgnoreUnchanged(plan.localIgnore);
|
|
541
721
|
const { target } = plan;
|
|
722
|
+
const stagedHuman = stagedDivergences(target);
|
|
723
|
+
const currentState = loadState(target);
|
|
724
|
+
const stagedChanges = plan.changes.filter(change => {
|
|
725
|
+
if (!stagedContains(target, stagedHuman, change.path)) return false;
|
|
726
|
+
const current = read(target, change.path);
|
|
727
|
+
if ((current ? hash(current) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
|
|
728
|
+
if (!current) return true;
|
|
729
|
+
if (change.stagedDeletionAllowed && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
|
|
730
|
+
&& currentState?.files?.[change.path] === change.beforeHash) return false;
|
|
731
|
+
if (change.stagedBlockAllowed) return false; // Exact source and planned bytes were checked above.
|
|
732
|
+
if (change.stagedMigrationTarget) {
|
|
733
|
+
const copy = plan.changes.find(candidate => candidate.path === change.stagedMigrationTarget && candidate.action === 'create');
|
|
734
|
+
if (copy?.content?.equals(current) && plan.changes.indexOf(copy) < plan.changes.indexOf(change)
|
|
735
|
+
&& !stat(safePath(target, change.stagedMigrationTarget))) return false;
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
});
|
|
739
|
+
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
740
|
if (plan.migration) {
|
|
543
741
|
if (stat(safePath(target, '.pbai4devs/state'))) fail('E_CHANGED', 'State destination appeared after planning.');
|
|
544
742
|
for (const directory of plan.migration.directories) {
|
|
@@ -574,7 +772,18 @@ export function applyPlan(plan) {
|
|
|
574
772
|
const file = safePath(target, change.path);
|
|
575
773
|
const content = change.content;
|
|
576
774
|
const signature = { path: change.path, hash: content === null ? null : hash(content) };
|
|
577
|
-
if (content === null) {
|
|
775
|
+
if (content === null) {
|
|
776
|
+
const migrated = plan.migration?.files.find(item => item.path === change.path);
|
|
777
|
+
if (migrated) {
|
|
778
|
+
const copy = plan.changes.find(candidate => candidate.path === migrated.target && candidate.action === 'create');
|
|
779
|
+
const copied = read(target, migrated.target);
|
|
780
|
+
if (!copy?.content || !copied?.equals(copy.content)
|
|
781
|
+
|| migrated.path !== legacyStatePath && !copied.equals(before.get(change.path))) {
|
|
782
|
+
fail('E_CHANGED', `Migrated copy changed before removing: ${change.path}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
rmSync(file, { force: true }); applied.push(signature);
|
|
786
|
+
}
|
|
578
787
|
else atomicWrite(target, change.path, content, beforeModes.get(change.path) ?? change.mode, mode => {
|
|
579
788
|
signature.mode = mode; applied.push(signature);
|
|
580
789
|
}, () => assertOriginal(change.path));
|