@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.
@@ -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
- const sameUrl=(a,b)=>{try{const x=new URL(a),y=new URL(b);return x.protocol===y.protocol&&x.host===y.host&&x.username===y.username&&x.password===y.password&&decodeURIComponent(x.pathname)===decodeURIComponent(y.pathname)&&x.search===y.search&&x.hash===y.hash;}catch{return false;}};
5
- if(!sameUrl(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'});
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 issue = svnStateIssue(copiedDescendant ? {...state, '@_item':'added'} : state);
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
- const leftProps = hasBase ? await properties(root, name, true, options) : {};
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 ('svn:special' in leftProps || 'svn:special' in rightProps) throw new Error('SVN special nodes are excluded');
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 (item.kind === 'file' && item.rawStatus !== 'deleted' && (await readLocal(root, item.path, options)).hash !== item.right.hash) throw new Error('Content changed during capture');
174
- const left = !item.targetAddition && !['added', 'unversioned'].includes(item.rawStatus) ? await properties(root, item.path, true, options) : {};
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 snapshot = { schemaVersion: 1, vcs: 'svn', root, repositoryUuid: rootInfo.repository.uuid, repositoryUrl:rootInfo.url, ...(mergeSources.length ? {mergeSources} : {}), files };
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
  }