@feng0531/dsh-code-review 0.79.0 → 0.80.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/CORE-ALIGNMENT-TASKS.md +19 -81
- package/CORE-SOURCE-INVENTORY.md +1 -1
- package/DEVELOPMENT.md +3 -2
- package/README.md +31 -5
- package/RELEASE-NOTES.md +9 -8
- package/SVN-RELEASE-EVIDENCE.md +114 -0
- package/SVN-SUPPORT-MATRIX.md +38 -119
- package/evaluation/README.md +1 -1
- package/index.mjs +1 -1
- package/package.json +2 -2
- package/src/caller-candidates.mjs +1 -1
- package/src/context-candidates.mjs +1 -1
- package/src/context-identity.mjs +8 -6
- package/src/file-relations.mjs +2 -1
- package/src/process.mjs +5 -1
- package/src/review.mjs +2 -0
- package/src/service.mjs +3 -2
- package/src/snapshot.mjs +10 -1
- package/src/svn-capture-budget.mjs +6 -0
- package/src/svn-dependency-chain.mjs +1 -1
- package/src/svn-diagnostics.mjs +16 -0
- package/src/svn-effective-mergeinfo.mjs +46 -0
- package/src/svn-history-callers.mjs +5 -5
- package/src/svn-history-dependencies.mjs +4 -3
- package/src/svn-history-endpoints.mjs +15 -0
- package/src/svn-history-lookup.mjs +4 -4
- package/src/svn-history.mjs +85 -46
- package/src/svn-keyword-config.mjs +10 -0
- package/src/svn-keyword-mapping.mjs +32 -15
- package/src/svn-replacement-source.mjs +25 -0
- package/src/svn-replacement-tree.mjs +66 -0
- package/src/svn-special.mjs +10 -0
- package/src/svn-url.mjs +8 -0
- package/src/svn-working-identity.mjs +4 -3
- package/src/svn-working-mergeinfo.mjs +31 -0
- package/src/svn-working-scope.mjs +7 -0
- package/src/svn-working-special.mjs +24 -0
- package/src/svn.mjs +56 -19
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {hash} from './content.mjs';
|
|
2
|
+
import {sameSvnUrl,canonicalSvnUrl} from './svn-url.mjs';
|
|
2
3
|
export function svnWorkingIdentity(node,rootInfo,name){
|
|
3
4
|
const expected=name==='.'?rootInfo.url:rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');
|
|
4
|
-
|
|
5
|
-
if(!
|
|
5
|
+
|
|
6
|
+
if(!sameSvnUrl(node?.url,expected))throw Object.assign(new Error('SVN node URL differs from selected working-copy path; switched ancestors are unsupported'),{code:'SVN_SWITCHED_ANCESTOR'});
|
|
6
7
|
if(!rootInfo.repository?.uuid||node.repository?.uuid!==rootInfo.repository.uuid||node.repository?.root!==rootInfo.repository.root||node['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw Object.assign(new Error('SVN node repository or working-copy identity mismatch'),{code:'SVN_NODE_IDENTITY'});
|
|
7
|
-
const source={url:node.url,repositoryUuid:node.repository.uuid,repositoryRoot:node.repository.root,revision:String(node['@_revision']),kind:node['@_kind'],schedule:node['wc-info']?.schedule,copyFromUrl:node['wc-info']?.['copy-from-url']??null,copyFromRevision:node['wc-info']?.['copy-from-rev']??null};
|
|
8
|
+
const source={url:canonicalSvnUrl(node.url),repositoryUuid:node.repository.uuid,repositoryRoot:node.repository.root,revision:String(node['@_revision']),kind:node['@_kind'],...(node['@_kind']==='dir'?{depth:node['wc-info']?.depth??null}:{}),schedule:node['wc-info']?.schedule,copyFromUrl:node['wc-info']?.['copy-from-url']??null,copyFromRevision:node['wc-info']?.['copy-from-rev']??null};
|
|
8
9
|
return {source,fingerprint:hash(JSON.stringify(source))};
|
|
9
10
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {checked} from './process.mjs';
|
|
2
|
+
import {xml} from './svn.mjs';
|
|
3
|
+
import {hash} from './content.mjs';
|
|
4
|
+
import {svnWorkingIdentity} from './svn-working-identity.mjs';
|
|
5
|
+
import {captureEffectiveMergeinfo} from './svn-effective-mergeinfo.mjs';
|
|
6
|
+
|
|
7
|
+
export async function workingEffectiveMergeinfo(root,rootInfo,files,options={}){
|
|
8
|
+
const signal=AbortSignal.any([AbortSignal.timeout(30000),...(options.signal?[options.signal]:[])]);let commands=0,bytes=0;const cache=new Map();
|
|
9
|
+
const run=async args=>{signal.throwIfAborted();if(++commands>512)throw new Error('SVN working mergeinfo command budget exceeded');const data=await checked('svn',['--non-interactive',...args],{...options,cwd:root,signal,maxBytes:256*1024});bytes+=data.length;if(bytes>1024*1024)throw new Error('SVN working mergeinfo metadata budget exceeded');return xml(data);};
|
|
10
|
+
const read=async(name,side,revision)=>{
|
|
11
|
+
const key=JSON.stringify([name,side]);if(cache.has(key)){const value=cache.get(key);if(side==='old'&&value.source.revision!==revision)throw new Error('Mixed BASE revisions cannot prove effective mergeinfo');return value;}
|
|
12
|
+
const node=(await run(['info','--xml','--',name+'@'])).info?.entry?.[0];const identity=svnWorkingIdentity(node,rootInfo,name);
|
|
13
|
+
const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty','--',name+'@'])).status?.target?.flatMap(t=>t.entry??[])??[];const state=entries[0]?.['wc-status'];
|
|
14
|
+
if(entries.length!==1||!state||['@_switched','@_file-external','@_tree-conflicted','@_copied'].some(k=>state[k]==='true')||!['normal','modified','deleted'].includes(state['@_item'])||state['@_props']==='conflicted')throw new Error('Unsafe ancestor state for effective mergeinfo');
|
|
15
|
+
if(side==='old'&&String(node['@_revision'])!==revision)throw new Error('Mixed BASE revisions cannot prove effective mergeinfo');
|
|
16
|
+
const props=(await run(['proplist','--xml','--verbose',...(side==='old'?['-r','BASE']:[]),'--',name+'@'])).properties?.target?.[0]?.property??[];const values=Object.create(null);
|
|
17
|
+
for(const p of props){if(typeof p['@_name']!=='string'||p['@_encoding']||Object.hasOwn(values,p['@_name']))throw new Error('Unsafe effective mergeinfo property representation');values[p['@_name']]=p['#text']??'';}
|
|
18
|
+
if(Object.hasOwn(values,'svn:externals')||Object.hasOwn(values,'svn:special'))throw new Error('Unsafe ancestor properties for effective mergeinfo');
|
|
19
|
+
const source={...identity.source,side,revision:String(node['@_revision']),propertyHash:hash(JSON.stringify(Object.entries(values).sort()))};
|
|
20
|
+
const record={path:name,present:Object.hasOwn(values,'svn:mergeinfo'),...(Object.hasOwn(values,'svn:mergeinfo')?{value:values['svn:mergeinfo']}:{}),identity:hash(JSON.stringify(source)),source};cache.set(key,record);return record;
|
|
21
|
+
};
|
|
22
|
+
const results=[];
|
|
23
|
+
for(const file of files.filter(f=>f.eligibility==='reviewable')){const value={path:file.path};for(const side of ['old','new']){
|
|
24
|
+
if(side==='old'&&['added','unversioned'].includes(file.rawStatus)||side==='new'&&file.rawStatus==='deleted'){value[side]={status:'absent',sources:[]};continue;}
|
|
25
|
+
if(file.replacementTreeRoot||file.replacementSource||file.copySource||file.targetAddition||file.rawStatus==='unversioned'){value[side]={status:'unknown',reason:'Added/copied/untracked ancestor semantics require separate provenance',sources:[]};continue;}
|
|
26
|
+
try{value[side]=await captureEffectiveMergeinfo(file.path,{signal,boundary:'.',complete:rootInfo.url===rootInfo.repository.root,read:name=>read(name,side,String(file.baseRevision))});for(const evidence of value[side].evidence??[])evidence.source=cache.get(JSON.stringify([evidence.path,side])).source;}catch(error){signal.throwIfAborted();if(error.code==='SVN_CAPTURE_BUDGET'||/budget exceeded/.test(error.message))throw error;value[side]={status:'unknown',reason:error.message,sources:[]};}
|
|
27
|
+
}results.push(value);}
|
|
28
|
+
// Re-read every captured property/identity after collection, charging same budget.
|
|
29
|
+
const original=[...cache.entries()];cache.clear();for(const [key,record]of original){const [name,side]=JSON.parse(key);if((await read(name,side,record.source.revision)).identity!==record.identity)throw new Error('Effective working mergeinfo changed during capture');}
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Describes observed evidence only; never infers a complete working tree from status.
|
|
2
|
+
export function workingScopeEvidence(rootSource,files){
|
|
3
|
+
const revisions=[...new Set(files.filter(f=>f.eligibility==='reviewable'&&f.workingSource&&/^[0-9]+$/.test(f.workingSource.revision)).map(f=>f.workingSource.revision))].sort((a,b)=>Number(a)-Number(b));
|
|
4
|
+
const sparse=files.filter(f=>f.workingSource?.kind==='dir'&&f.workingSource.depth!=='infinity').map(f=>({path:f.path,depth:f.workingSource.depth??'unknown'}));
|
|
5
|
+
if(rootSource.depth!=='infinity'&&!sparse.some(s=>s.path==='.'))sparse.unshift({path:'.',depth:rootSource.depth??'unknown'});
|
|
6
|
+
return {root:rootSource,observedBaseRevisions:revisions,mixedObservedRevisions:revisions.length>1,sparseDirectories:sparse,inventoryComplete:false,notice:'Only selected/observed change metadata is described. Mixed revisions bind each node BASE independently, not a common historical tree. Sparse or unenumerated paths are not deletion evidence or reviewed coverage; no update/deepen/external traversal was performed.'};
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {lstat,readlink,realpath} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {relativePath,readLocal,decode} from './content.mjs';
|
|
4
|
+
import {assertNonSecretPath} from './secret-path.mjs';
|
|
5
|
+
import {svnSpecialData} from './svn-special.mjs';
|
|
6
|
+
|
|
7
|
+
// Validate ancestors but never realpath/open a symbolic-link leaf or its target.
|
|
8
|
+
export async function readWorkingSvnSpecial(root,name,properties,options={}){
|
|
9
|
+
relativePath(name);assertNonSecretPath(name);options.signal?.throwIfAborted();
|
|
10
|
+
const canonical=await realpath(root);let cursor=canonical;const parts=name.split('/');
|
|
11
|
+
for(const part of parts.slice(0,-1)){cursor=path.join(cursor,part);const stat=await lstat(cursor);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN special ancestor must be a real directory');}
|
|
12
|
+
const filename=path.join(cursor,parts.at(-1)),before=await lstat(filename);let content;
|
|
13
|
+
if(before.isSymbolicLink()){
|
|
14
|
+
const target=await readlink(filename,{encoding:'buffer'});if(target.length>16384)throw new Error('SVN link target byte limit exceeded');
|
|
15
|
+
const bytes=Buffer.concat([Buffer.from('link '),target]);if(bytes.length>(options.maxFileBytes??256*1024))throw new Error('SVN special file size limit exceeded');
|
|
16
|
+
content=decode(bytes);const after=await lstat(filename);const again=await readlink(filename,{encoding:'buffer'});
|
|
17
|
+
if(!after.isSymbolicLink()||before.ino!==after.ino||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||!again.equals(target))throw new Error('SVN link changed during capture');
|
|
18
|
+
}else{
|
|
19
|
+
if(!before.isFile())throw new Error('SVN special working node is neither link nor regular link record');
|
|
20
|
+
content=await readLocal(canonical,name,options);
|
|
21
|
+
}
|
|
22
|
+
options.signal?.throwIfAborted();
|
|
23
|
+
return {content,special:svnSpecialData(content,properties,'file')};
|
|
24
|
+
}
|
package/src/svn.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {captureReplacementTree} from './svn-replacement-tree.mjs';
|
|
2
|
+
import {readReplacementSource} from './svn-replacement-source.mjs';
|
|
3
|
+
import {workingScopeEvidence} from './svn-working-scope.mjs';
|
|
4
|
+
import {workingEffectiveMergeinfo} from './svn-working-mergeinfo.mjs';
|
|
5
|
+
import {svnSpecialData} from './svn-special.mjs';
|
|
6
|
+
import {readWorkingSvnSpecial} from './svn-working-special.mjs';
|
|
1
7
|
import {svnWorkingIdentity} from './svn-working-identity.mjs';
|
|
2
8
|
import { isSecretPath } from './secret-path.mjs';
|
|
3
9
|
import { svnStateIssue } from './svn-state.mjs';
|
|
@@ -10,7 +16,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
10
16
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
11
17
|
import { checked } from './process.mjs';
|
|
12
18
|
import { hash, decode, readLocal, relativePath } from './content.mjs';
|
|
13
|
-
const parser = new XMLParser({ ignoreAttributes: false, parseTagValue: false, parseAttributeValue: false, trimValues: false, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
19
|
+
const parser = new XMLParser({ htmlEntities: {}, ignoreAttributes: false, parseTagValue: false, parseAttributeValue: false, trimValues: false, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
14
20
|
export function xml(bytes) {
|
|
15
21
|
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
16
22
|
if (/<!DOCTYPE|<!ENTITY/i.test(source) || XMLValidator.validate(source) !== true) throw new Error('Unsafe or invalid SVN XML');
|
|
@@ -67,14 +73,21 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
67
73
|
if (originalEntries.length > maxFiles) throw new Error('Change count limit exceeded');
|
|
68
74
|
const entries = await structuralEntries(root, originalEntries, options, maxFiles);
|
|
69
75
|
const known = new Set(entries.map(e => e['@_path'].replaceAll('\\', '/')));
|
|
70
|
-
if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
|
|
71
|
-
const files = [];
|
|
76
|
+
if (selectedPaths?.some(p => !known.has(p)&&!(options.svnReplacementBaseRevision!==undefined&&entries.some(e=>e['wc-status']?.['@_item']==='replaced'&&p.startsWith(e['@_path'].replaceAll('\\','/')+'/'))))) throw new Error('Selected path is not a current change');
|
|
77
|
+
const files = [],replacementTrees=[];
|
|
78
|
+
const replacementRoots=options.svnReplacementBaseRevision===undefined?[]:entries.filter(e=>e['wc-status']?.['@_item']==='replaced').map(e=>e['@_path'].replaceAll('\\','/')).sort().filter((p,i,all)=>!all.slice(0,i).some(parent=>p.startsWith(parent+'/')));
|
|
79
|
+
for(const name of replacementRoots){
|
|
80
|
+
if(isSecretPath(name))continue;
|
|
81
|
+
if(options.selectedPaths&&!options.selectedPaths.some(p=>p===name||p.startsWith(name+'/')))continue;
|
|
82
|
+
const tree=await captureReplacementTree(root,rootInfo,name,options);replacementTrees.push(tree);files.push(...tree.files);if(files.length>maxFiles)throw new Error('Combined replacement change count limit exceeded');
|
|
83
|
+
}
|
|
72
84
|
for (const entry of entries) {
|
|
73
85
|
signal?.throwIfAborted();
|
|
74
86
|
const name = entry['@_path'].replaceAll('\\', '/');
|
|
87
|
+
if(replacementTrees.some(tree=>tree.files.some(f=>name===f.replacementTreeRoot||name.startsWith(f.replacementTreeRoot+'/'))))continue;
|
|
75
88
|
const state = entry['wc-status'], status = state['@_item'];
|
|
76
89
|
const item = { id: hash(name), path: name, ...(name === '.' ? {kind: 'dir'} : {}), rawStatus: status, baseRevision: state['@_revision'], eligibility: 'reviewable', properties: [] };
|
|
77
|
-
files.push(item);
|
|
90
|
+
files.push(item);if(files.length>maxFiles)throw new Error('Combined change count limit exceeded');
|
|
78
91
|
if (isSecretPath(name)) { item.eligibility='excluded'; item.reason='Credential path excluded'; continue; }
|
|
79
92
|
if ((selectedPaths && !selectedPaths.includes(name)) || (status === 'unversioned' && !selectedPaths?.includes(name))) {
|
|
80
93
|
item.eligibility = 'excluded'; item.reason = 'Not explicitly selected'; continue;
|
|
@@ -82,7 +95,8 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
82
95
|
try {
|
|
83
96
|
if (name !== '.') relativePath(name);
|
|
84
97
|
const copiedDescendant = !!entry.copyRoot && ['normal', 'modified', 'added'].includes(status) && state['@_copied'] === 'true';
|
|
85
|
-
const
|
|
98
|
+
const independentReplacement=status==='replaced'&&options.svnReplacementBaseRevision!==undefined;
|
|
99
|
+
const issue = svnStateIssue(independentReplacement?{...state,'@_item':'modified','@_copied':'false'}:copiedDescendant ? {...state, '@_item':'added'} : state);
|
|
86
100
|
if (issue) { item.blockCode = issue.code; throw new Error(issue.reason); }
|
|
87
101
|
const hasBase = !copiedDescendant && !['added', 'unversioned'].includes(status);
|
|
88
102
|
let kind;
|
|
@@ -92,7 +106,7 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
92
106
|
const identity=svnWorkingIdentity(node,rootInfo,name);
|
|
93
107
|
item.workingSource=identity.source;item.workingIdentity=identity.fingerprint;
|
|
94
108
|
kind = node['@_kind'];
|
|
95
|
-
if (state['@_copied'] === 'true' || node['wc-info']?.['copy-from-url']) {
|
|
109
|
+
if (!independentReplacement&&(state['@_copied'] === 'true' || node['wc-info']?.['copy-from-url'])) {
|
|
96
110
|
let source = node['wc-info']?.['copy-from-url'], revision = node['wc-info']?.['copy-from-rev'];
|
|
97
111
|
if (!source && copiedDescendant) {
|
|
98
112
|
const parent = files.find(file => file.path === entry.copyRoot);
|
|
@@ -111,35 +125,48 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
111
125
|
item.kind = kind;
|
|
112
126
|
if (copiedDescendant) item.targetAddition = true;
|
|
113
127
|
item.rightExists = status !== 'deleted';
|
|
114
|
-
|
|
128
|
+
if(independentReplacement&&!['file','dir'].includes(kind))throw new Error('Unsupported replacement target kind');
|
|
129
|
+
const replacement=independentReplacement?await readReplacementSource(root,rootInfo,name,options.svnReplacementBaseRevision,options):null;
|
|
130
|
+
if(replacement?.descendants.length){item.blockCode='SVN_REPLACEMENT_OLD_DESCENDANTS';throw new Error('Replacement old directory descendants require separate dual-side tree capture; no implicit old child approval');}
|
|
131
|
+
if(replacement&&kind==='dir'){
|
|
132
|
+
const inventory=xml(await svn(root,['status','--xml','--verbose','--ignore-externals','--depth','infinity','--',target(name)],options)).status?.target?.flatMap(t=>t.entry??[])??[];
|
|
133
|
+
const descendants=inventory.filter(entry=>entry['@_path'].replaceAll('\\','/')!==name);
|
|
134
|
+
if(descendants.length||item.workingSource.depth!=='infinity'){item.blockCode='SVN_REPLACEMENT_NEW_DESCENDANTS';throw new Error('Replacement target directory is nonempty or sparse; descendants require separate node approval');}
|
|
135
|
+
item.replacementEmptyDirectory=true;
|
|
136
|
+
}
|
|
137
|
+
if(replacement){item.oldKind=replacement.kind;item.newKind=kind;item.nodeNotice='Independent fixed repository baseline and current target have separate node kinds; directories are not empty source files.';item.baseRevision=replacement.source.operativeRevision;item.replacementSource=replacement.source;item.contentNotice='Explicit repository revision baseline versus replacement working target; not local BASE, copy-source content, or proof that selected revision equals the removed node.';}
|
|
138
|
+
const leftProps = replacement?replacement.props:hasBase ? await properties(root, name, true, options) : {};
|
|
115
139
|
const rightProps = status === 'deleted' || status === 'unversioned' ? {} : await properties(root, name, false, options);
|
|
116
|
-
if
|
|
140
|
+
if(kind!=='file'&&('svn:special' in leftProps||'svn:special' in rightProps))throw new Error('SVN special property requires file-node link data');
|
|
117
141
|
const keywordText=!!(leftProps['svn:keywords']||rightProps['svn:keywords']);
|
|
118
|
-
if(keywordText&&kind!=='file')throw new Error('SVN keyword mapping requires regular file');
|
|
142
|
+
if(keywordText&&(kind!=='file'||replacement&&replacement.kind!=='file'))throw new Error('SVN keyword mapping requires regular file');
|
|
119
143
|
item.textNotice = 'Text preserves SVN cat output and working bytes. CRLF/LF differences alone are not code defects.';
|
|
120
144
|
for (const key of [...new Set([...Object.keys(leftProps), ...Object.keys(rightProps)])].sort()) {
|
|
121
145
|
if (leftProps[key] !== rightProps[key]) item.properties.push({ name: key, old: leftProps[key] ?? null, new: rightProps[key] ?? null });
|
|
122
146
|
}
|
|
123
147
|
item.propertyIdentity = hash(JSON.stringify([leftProps, rightProps]));
|
|
124
|
-
if (kind === 'dir') {
|
|
148
|
+
if (kind === 'dir'&&(!replacement||replacement.kind==='dir')) {
|
|
125
149
|
if (status === 'unversioned') throw new Error('Unversioned directory requires explicit descendant tracking');
|
|
126
150
|
if (status === 'deleted' || status === 'added' || copiedDescendant) {
|
|
127
151
|
const descendants = entries.filter(entry => entry['@_path'].replaceAll('\\', '/').startsWith(name + '/')).map(entry => entry['@_path'].replaceAll('\\', '/'));
|
|
128
152
|
if (selectedPaths && descendants.some(child => !selectedPaths.includes(child))) throw new Error('Directory descendants are not all explicitly selected');
|
|
153
|
+
if(item.workingSource?.depth!=='infinity'){item.blockCode='SVN_SPARSE_STRUCTURE';throw new Error('Sparse or unknown directory depth cannot prove complete structural descendant coverage');}
|
|
129
154
|
item.structuralDescendants = descendants;
|
|
130
155
|
item.structuralNotice = 'Directory node coverage is separate from each descendant; no implicit descendant approval.';
|
|
131
156
|
}
|
|
132
|
-
item.left = decode(Buffer.alloc(0)); item.right = decode(Buffer.alloc(0));
|
|
157
|
+
item.left = replacement?replacement.content:decode(Buffer.alloc(0)); item.right = decode(Buffer.alloc(0));
|
|
133
158
|
} else {
|
|
134
|
-
item.left = decode(hasBase ? await svn(root, ['cat', '-r', 'BASE', '--', target(name)], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0));
|
|
135
|
-
item.right = status === 'deleted' ? decode(Buffer.alloc(0)) : await readLocal(root, name, options);
|
|
159
|
+
item.left = replacement?replacement.content:decode(hasBase ? await svn(root, ['cat', '-r', 'BASE', '--', target(name)], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0));
|
|
160
|
+
item.right = status === 'deleted'||kind==='dir' ? decode(Buffer.alloc(0)) : Object.hasOwn(rightProps,'svn:special')?(await readWorkingSvnSpecial(root,name,rightProps,options)).content:await readLocal(root, name, options);
|
|
161
|
+
const oldSpecial=svnSpecialData(item.left,leftProps,replacement?.kind??kind),newSpecial=svnSpecialData(item.right,rightProps,kind);
|
|
162
|
+
if(oldSpecial||newSpecial){item.specialData={old:oldSpecial,new:newSpecial};item.contentNotice='SVN link record data only on special sides; target strings authorize no target reads or source navigation.';}
|
|
136
163
|
if(Object.hasOwn(leftProps,'svn:eol-style')||Object.hasOwn(rightProps,'svn:eol-style')){
|
|
137
164
|
item.eolMapping={old:svnEolMapping(item.left.text,leftProps['svn:eol-style']??null),new:svnEolMapping(item.right.text,rightProps['svn:eol-style']??null)};
|
|
138
165
|
item.textNotice+=' EOL mapping is hash-only metadata; raw text and LF evidence lines are authoritative. Lone CR has no identity mapping to normalized LF lines.';
|
|
139
166
|
}
|
|
140
167
|
if(keywordText){
|
|
141
168
|
const old=svnKeywordMapping(item.left.text,leftProps['svn:keywords']??''),current=svnKeywordMapping(item.right.text,rightProps['svn:keywords']??'');
|
|
142
|
-
if(hasBase){
|
|
169
|
+
if(hasBase&&!replacement){
|
|
143
170
|
const repository=decode(await svn(root,['cat','--ignore-keywords','-r','BASE','--',target(name)],{...options,maxBytes:maxFileBytes}));
|
|
144
171
|
if(old.text!==repository.text)throw new Error('SVN BASE keyword mapping differs from repository text');
|
|
145
172
|
}
|
|
@@ -149,7 +176,7 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
149
176
|
}
|
|
150
177
|
}
|
|
151
178
|
} catch (error) {
|
|
152
|
-
signal?.throwIfAborted(); item.eligibility = 'blocked'; item.reason = error.message;if(error.code)item.blockCode=error.code;
|
|
179
|
+
signal?.throwIfAborted();if(error.code==='SVN_CAPTURE_BUDGET')throw error; item.eligibility = 'blocked'; item.reason = error.message;if(error.code)item.blockCode=error.code;
|
|
153
180
|
delete item.left; delete item.right;
|
|
154
181
|
}
|
|
155
182
|
}
|
|
@@ -163,21 +190,31 @@ export async function captureSvn(cwd, options = {}) {
|
|
|
163
190
|
delete item.left; delete item.right;
|
|
164
191
|
}
|
|
165
192
|
}
|
|
166
|
-
for (const item of files.filter(f => f.eligibility === 'reviewable')) {
|
|
193
|
+
for (const item of files.filter(f => f.eligibility === 'reviewable'&&!f.replacementTreeRoot)) {
|
|
167
194
|
if(item.workingIdentity){const node=xml(await svn(root,['info','--xml','--',target(item.path)],options)).info.entry[0];if(svnWorkingIdentity(node,rootInfo,item.path).fingerprint!==item.workingIdentity)throw new Error('SVN node identity changed during capture');}
|
|
195
|
+
if(item.replacementEmptyDirectory){const inventory=xml(await svn(root,['status','--xml','--verbose','--ignore-externals','--depth','infinity','--',target(item.path)],options)).status?.target?.flatMap(t=>t.entry??[])??[];if(inventory.some(e=>e['@_path'].replaceAll('\\','/')!==item.path))throw new Error('Replacement target directory inventory changed during capture');}
|
|
168
196
|
if (item.copySource) {
|
|
169
197
|
const node = xml(await svn(root, ['info', '--xml', '--', target(item.path)], options)).info.entry[0];
|
|
170
198
|
const identity = hash(JSON.stringify([node.url, node['@_kind'], node['@_revision'], node['wc-info']?.schedule, node['wc-info']?.['wcroot-abspath'], node['wc-info']?.['copy-from-url'] ?? null, node['wc-info']?.['copy-from-rev'] ?? null, node.repository]));
|
|
171
199
|
if (identity !== item.copyNodeIdentity) throw new Error('Copy source identity changed during capture');
|
|
172
200
|
}
|
|
173
|
-
if
|
|
174
|
-
|
|
201
|
+
if(item.kind==='file'&&item.rawStatus!=='deleted'){
|
|
202
|
+
const current=item.specialData?.new?(await readWorkingSvnSpecial(root,item.path,await properties(root,item.path,false,options),options)).content:await readLocal(root,item.path,options);
|
|
203
|
+
if(current.hash!==item.right.hash)throw new Error('Content changed during capture');
|
|
204
|
+
}
|
|
205
|
+
const replacement=item.replacementSource?await readReplacementSource(root,rootInfo,item.path,options.svnReplacementBaseRevision,options):null;
|
|
206
|
+
if(replacement&&JSON.stringify(replacement.source)!==JSON.stringify(item.replacementSource))throw new Error('Replacement repository baseline changed during capture');
|
|
207
|
+
const left = replacement?replacement.props:!item.targetAddition && !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
|
|
175
208
|
const right = ['deleted', 'unversioned'].includes(item.rawStatus) ? {} : await properties(root, item.path, false, options);
|
|
176
209
|
if (hash(JSON.stringify([left, right])) !== item.propertyIdentity) throw new Error('Properties changed during capture');
|
|
177
210
|
}
|
|
211
|
+
for(const tree of replacementTrees)await tree.verify();
|
|
178
212
|
const finalRootInfo=xml(await svn(root,['info','--xml','--','.@'],options)).info.entry[0];
|
|
179
213
|
if(svnWorkingIdentity(finalRootInfo,rootInfo,'.').fingerprint!==svnWorkingIdentity(rootInfo,rootInfo,'.').fingerprint)throw new Error('SVN root identity changed during capture');
|
|
214
|
+
if(options.svnEffectiveMergeinfo!==undefined&&typeof options.svnEffectiveMergeinfo!=='boolean')throw new Error('Invalid SVN effective mergeinfo opt-in');
|
|
215
|
+
const effectiveMergeSources=options.svnEffectiveMergeinfo===true?await workingEffectiveMergeinfo(root,rootInfo,files,options):undefined;
|
|
180
216
|
const mergeSources = snapshotMergeSources(files);
|
|
181
|
-
const
|
|
217
|
+
const workingScope=workingScopeEvidence(svnWorkingIdentity(rootInfo,rootInfo,'.').source,files);
|
|
218
|
+
const snapshot = { schemaVersion: 1, vcs: 'svn', root, workingScope, repositoryUuid: rootInfo.repository.uuid, repositoryUrl:rootInfo.url,...(effectiveMergeSources?{effectiveMergeSources}:{}), ...(mergeSources.length ? {mergeSources} : {}), files };
|
|
182
219
|
return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
|
|
183
220
|
}
|