@feng0531/dsh-vcs 0.4.10 → 0.5.1

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/vcs.mjs CHANGED
@@ -13,14 +13,19 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser';
13
13
  import {decode, MAX_TEXT, MAX_LARGE_TEXT, decodeLargeText} from './text-content.mjs';
14
14
  import {inside, confined} from './repository-path.mjs';
15
15
  import {readWorkspaceLargeText} from './workspace-large-text.mjs';
16
+ import {createHistoricalAccess,validateHistoricalPath} from './src/historical-access.mjs';
17
+ import {withComparisonPreview} from './src/preview-capabilities.mjs';
18
+
19
+ const historicalAccess = createHistoricalAccess({run:git});
20
+ /** Host invalidates object bytes on grant replacement, expiry and plugin disposal. */
21
+ export const clearHistoricalCache = root => historicalAccess.clear(root);
22
+ export const historicalCacheStats = () => historicalAccess.stats();
16
23
 
17
24
  // Shared read-only adapter bounds. Values are part of the public contract:
18
25
  // history pagination, per-result entry caps and segmented-preview byte budgets.
19
26
  const MAX_HISTORY_OFFSET = 10000;
20
27
  const MAX_HISTORY_PAGE = 100;
21
28
  const MAX_RESULT_ENTRIES = 10000;
22
- const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
23
- const SEGMENT_CHUNK_BYTES = 64 * 1024;
24
29
  const RENAME_CONFIRMATION_LIMIT = 32;
25
30
  const METADATA_BATCH_SIZE = 64;
26
31
  const METADATA_BATCH_BYTES = 24000;
@@ -277,9 +282,10 @@ export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
277
282
  catch{throw new Error('Content search requires a resolved HEAD commit');}
278
283
  }
279
284
  if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
285
+ if((await git(repo.root,['cat-file','-t',snapshot],MAX_TEXT,{signal})).toString('utf8').trim()!=='commit')throw Error('Content search requires a commit object');
280
286
  let text;
281
287
  try{text=(await git(repo.root,['-c','grep.column=false','grep','--no-color','-F','-n','-I','-z','--max-count=100','-e',query,snapshot,'--'],MAX_TEXT,{signal})).toString('utf8');}
282
- catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {matches:[]};throw error;}
288
+ catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {snapshot,matches:[]};throw error;}
283
289
  signal?.throwIfAborted();
284
290
  const matches=[];
285
291
  // -z frames path and line with NUL; legal paths can contain LF.
@@ -294,7 +300,7 @@ export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
294
300
  matches.push({path,line:Number(lineNo),text:lineText});
295
301
  if(matches.length>500)throw new Error('Content search exceeds match limit');
296
302
  }
297
- return {matches};
303
+ return {snapshot,matches};
298
304
  }
299
305
 
300
306
  export async function getCommitDetails(repo,options={}) {
@@ -343,39 +349,30 @@ function parseCommitChanges(text,scope){
343
349
  }
344
350
  return changes;
345
351
  }
346
- export async function getHistoricalSegment(repo,{commit,path:file,offset=0,signal}={}){
347
- if(!Number.isInteger(offset)||offset<0||offset>MAX_SEGMENT_BYTES)throw Error('Invalid segment offset');
348
- if(typeof file!=='string'||!file||file.includes('\0'))throw Error('Invalid historical path');
349
- repo=await checkedRepo(repo,signal);
350
- const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
351
- if(!entry||entry.type!=='blob'||!['100644','100755'].includes(entry.mode))throw Error('Segment requires a selected committed regular file');
352
- const sizeText=(await git(repo.root,['cat-file','-s',entry.oid],MAX_TEXT,{signal})).toString('utf8').trim();
353
- if(!/^\d+$/.test(sizeText))throw Error('Invalid object size');
354
- const size=Number(sizeText);
355
- if(size>MAX_SEGMENT_BYTES)throw Error('Segmented file exceeds 16 MiB limit');
356
- if(offset>size)throw Error('Segment offset exceeds file size');
357
- const buffer=await git(repo.root,['cat-file','blob',entry.oid],MAX_SEGMENT_BYTES,{signal});
358
- if(buffer.length!==size)throw Error('Historical blob size mismatch');
359
- if(buffer.includes(0)||buffer.some(byte=>byte<32&&![9,10,12,13].includes(byte)))throw Error('Segments support UTF-8 text only');
360
- new TextDecoder('utf-8',{fatal:true}).decode(buffer);
361
- if(offset<size&&(buffer[offset]&192)===128)throw Error('Segment offset splits UTF-8 character');
362
- let end=Math.min(size,offset+SEGMENT_CHUNK_BYTES);
363
- while(end<size&&(buffer[end]&192)===128)end--;
364
- const text=new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(offset,end));
365
- signal?.throwIfAborted();
366
- return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end<size?end:null,text};
367
- }
368
- export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
369
- if(typeof file!=='string'||!file||file.includes('\0'))throw new Error('Invalid historical path');
370
- repo=await checkedRepo(repo,signal);
371
- const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
372
- if(!entry)throw new Error('Path is not part of selected commit');
373
- if(entry.type!=='blob'||!['100644','100755'].includes(entry.mode))throw new Error('Historical file preview requires a regular file');
374
- const result=await content(()=>git(repo.root,['cat-file','blob',entry.oid],MAX_TEXT,{signal}));
352
+ export async function getHistoricalSegment(repo, {commit,path:file,offset = 0,signal} = {}) {
353
+ const expectedEpoch = historicalAccess.captureEpoch();
354
+ // Capture before any await: revoked in-flight validation cannot refill object bytes.
355
+ repo = await checkedGitRepo(repo, signal, 'Historical segments support Git only');
356
+ return historicalAccess.segment(repo.root, {commit,path:file,offset,signal,expectedEpoch});
357
+ }
358
+
359
+ export async function getHistoricalFile(repo,{commit,path:file,signal}={}) {
360
+ if(typeof file !== 'string' || !file || file.includes('\0')) throw Error('Invalid historical path');
361
+ try {validateHistoricalPath(file);} catch {throw Error('Path is not part of selected commit');}
362
+ repo = await checkedGitRepo(repo, signal, 'Historical file supports Git only');
363
+ const entry = await historicalAccess.lookup(repo.root, {commit,path:file,signal});
364
+ if (entry.type !== 'blob' || !['100644','100755'].includes(entry.mode)) throw Error('Historical file preview requires a regular file');
365
+ const result = await content(() => git(repo.root, ['cat-file','blob',entry.oid], MAX_TEXT, {signal}));
375
366
  signal?.throwIfAborted();
376
367
  return {commit,path:file,oid:entry.oid,...result};
377
368
  }
378
- export async function getHistoricalTree(repo,{commit,signal}={}){
369
+
370
+ export async function getHistoricalTreePage(repo, options = {}) {
371
+ repo = await checkedGitRepo(repo, options.signal, 'Historical tree supports Git only');
372
+ return historicalAccess.treePage(repo.root, options);
373
+ }
374
+
375
+ export async function getHistoricalTree(repo,{commit,signal}={}) {
379
376
  if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
380
377
  repo=await checkedGitRepo(repo,signal,'Historical tree supports Git only');
381
378
  const type=(await git(repo.root,['cat-file','-t',commit],MAX_TEXT,{signal})).toString('utf8').trim();if(type!=='commit')throw new Error('Commit object required');
@@ -438,7 +435,9 @@ export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}
438
435
  if(!before.isFile()||before.isSymbolicLink())throw Error('Image preview requires a regular file');if(before.size>MAX_TEXT)throw Error('Image exceeds 2 MiB limit');
439
436
  const handle=await fs.open(target,fileFlags.O_RDONLY|(fileFlags.O_NOFOLLOW||0));try{
440
437
  const fingerprint=s=>JSON.stringify([s.dev,s.ino,s.size,s.mtimeMs,s.ctimeMs]);const opened=await handle.stat();if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working image changed during read');
441
- const buffer=Buffer.alloc(MAX_TEXT+1);const {bytesRead}=await handle.read(buffer,0,buffer.length,0);signal?.throwIfAborted();const after=await handle.stat(),named=await fs.lstat(target);await confined(repo.root,file);
438
+ const buffer=Buffer.alloc(before.size+1);let bytesRead=0;
439
+ while(bytesRead<buffer.length){signal?.throwIfAborted();const result=await handle.read(buffer,bytesRead,buffer.length-bytesRead,bytesRead);if(!result.bytesRead)break;bytesRead+=result.bytesRead;}
440
+ signal?.throwIfAborted();const after=await handle.stat(),named=await fs.lstat(target);await confined(repo.root,file);
442
441
  if(named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before)||bytesRead!==before.size)throw Error('Working image changed during read');
443
442
  result={path:file,commit:'Working tree',...await workspacePng(buffer.subarray(0,bytesRead),signal)};
444
443
  verifyWorking=async()=>{await confined(repo.root,file);const validated=await fs.lstat(target);if(validated.isSymbolicLink()||fingerprint(validated)!==fingerprint(before))throw Error('Working image changed during validation');};await verifyWorking();
@@ -638,43 +637,47 @@ async function changes(repo, mode, options) {
638
637
  export async function listChanges(repo, mode = 'all', {signal} = {}) { repo = await checkedRepo(repo,signal); modeFor(repo, mode); const result=await changes(repo, mode,{signal});signal?.throwIfAborted();return result; }
639
638
  // Keep the public adapter decoding export compatible with existing consumers.
640
639
  export {decode} from './text-content.mjs';
641
- async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
640
+ async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', previewReason:'size-limit', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
642
641
  async function working(root, relative, svnLink = false, signal) {
643
642
  signal?.throwIfAborted();
644
643
  const target = await confined(root, relative);
645
644
  let stat; try { stat = await fs.lstat(target); } catch (e) { if (e.code === 'ENOENT') return { text: '' }; throw e; }
646
- if (stat.isSymbolicLink()) return { text: (svnLink ? 'link ' : '') + await fs.readlink(target) };
647
- if (!stat.isFile()) return { text: '', notice: 'Directory or special file; no file-content preview.' };
648
- if (stat.size > MAX_TEXT) return { text: '', notice: 'File exceeds the 2 MiB preview limit.' };
645
+ if (stat.isSymbolicLink()) return { text:(svnLink ? 'link ' : '') + await fs.readlink(target), largeEligible:false };
646
+ if (!stat.isFile()) return { text:'', previewReason:'unsupported-type', largeEligible:false, notice:'Directory or special file; no file-content preview.' };
647
+ if (stat.size > MAX_TEXT) return { text:'', previewReason:'size-limit', largeEligible:stat.size<=MAX_LARGE_TEXT, notice:'File exceeds the 2 MiB preview limit.' };
649
648
  const fingerprint=s=>[s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(':');
650
649
  const before=await fs.lstat(target,{bigint:true});
651
- if(!before.isFile()||before.size>BigInt(MAX_TEXT))throw Error('Working file changed before read; refresh status');
650
+ if(!before.isFile()||before.size>BigInt(MAX_TEXT))throw selectionChanged('Working file changed before read; refresh status');
652
651
  const handle = await fs.open(target,fileFlags.O_RDONLY|(fileFlags.O_NOFOLLOW||0));
653
652
  try {
654
- const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working file changed during open');
653
+ const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw selectionChanged('Working file changed during open');
655
654
  const buffer=Buffer.alloc(Number(before.size)+1);let offset=0;
656
655
  while(offset<buffer.length){signal?.throwIfAborted();const {bytesRead}=await handle.read(buffer,offset,buffer.length-offset,offset);if(!bytesRead)break;offset+=bytesRead;}
657
656
  signal?.throwIfAborted();await confined(root,relative);
658
657
  const after=await handle.stat({bigint:true}),named=await fs.lstat(target,{bigint:true});
659
- if(offset!==Number(before.size)||!named.isFile()||named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before))throw Error('Working file changed during read; refresh status');
658
+ if(offset!==Number(before.size)||!named.isFile()||named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before))throw selectionChanged('Working file changed during read; refresh status');
660
659
  return decode(buffer.subarray(0,offset));
661
660
  } finally { await handle.close(); }
662
661
  }
663
- async function gitBlob(root, revision, file, options) {
662
+ const selectionChanged = message => Object.assign(Error(message || 'Workspace selection changed; refresh repository status'), {code:'vcs/selection-changed'});
663
+
664
+ async function gitBlob(root, revision, file, options, indexSnapshot) {
664
665
  let record;
665
666
  if(revision){
666
667
  const raw=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,options)).toString('utf8');
667
668
  const row=raw.split('\0').find(row=>row.includes('\t')&&row.slice(row.indexOf('\t')+1)===file);
668
669
  if(!row)return {text:''};const [mode,type,oid]=row.slice(0,row.indexOf('\t')).split(' ');record={mode,type,oid};
669
670
  }else{
670
- const raw=(await git(root,['ls-files','--stage','-z','--',file],MAX_TEXT,options)).toString('utf8');
671
+ const raw=(indexSnapshot ?? await git(root,['ls-files','--stage','-z','--',file],MAX_TEXT,options)).toString('utf8');
671
672
  for(const row of raw.split('\0')){const tab=row.indexOf('\t');if(tab<0||row.slice(tab+1)!==file)continue;const [mode,oid,stage]=row.slice(0,tab).split(' ');if(stage==='0')record={mode,oid,type:mode==='160000'?'commit':'blob'};}
672
673
  if(!record)return {text:'',notice:'No stage-0 Index entry; conflict stages are not inferred.'};
673
674
  }
674
675
  if(!isObjectId(record.oid))throw Error('Invalid selected object');
675
- if(record.mode==='160000')return {text:'Submodule '+record.oid,notice:'Recorded gitlink pointer; nested checkout commit and contents are not loaded by this preview.'};
676
- if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',notice:'Unsupported selected object; contents not loaded.'};
676
+ if(record.mode==='160000')return {text:'Submodule '+record.oid,largeEligible:false,notice:'Recorded gitlink pointer; nested checkout commit and contents are not loaded by this preview.'};
677
+ if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',previewReason:'unsupported-type',largeEligible:false,notice:'Unsupported selected object; contents not loaded.'};
677
678
  const body=await content(()=>git(root,['cat-file','blob',record.oid],MAX_TEXT,options));
679
+ if(body.previewReason==='size-limit'){const size=(await git(root,['cat-file','-s',record.oid],MAX_TEXT,options)).toString('utf8').trim();body.largeEligible=/^\d+$/.test(size)&&Number(size)<=MAX_LARGE_TEXT;}
680
+ if(record.mode==='120000')body.largeEligible=false;
678
681
  if(record.mode==='120000')body.notice=[body.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
679
682
  return body;
680
683
  }
@@ -685,8 +688,8 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
685
688
  if(!record)throw new Error('Historical path missing');
686
689
  const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
687
690
  if(large&&(!['100644','100755'].includes(mode)||type!=='blob'))throw new Error('Large comparison requires a committed regular file.');
688
- if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
689
- if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
691
+ if(mode==='160000')return {text:oid,largeEligible:false,notice:'Submodule commit reference; repository contents are not loaded.'};
692
+ if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',previewReason:'unsupported-type',largeEligible:false,notice:'Unsupported historical object type.'};
690
693
  if (large) {
691
694
  const rawSize = (await git(root, ['cat-file', '-s', oid], MAX_TEXT, budget?budget():{signal})).toString('utf8').trim();
692
695
  if (!/^\d+$/.test(rawSize)) throw new Error('Invalid historical object size.');
@@ -697,7 +700,9 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
697
700
  return decodeLargeText(buffer, signal);
698
701
  }
699
702
 
700
- const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
703
+ const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,budget?budget():{signal}));
704
+ if(result.previewReason==='size-limit'){const size=(await git(root,['cat-file','-s',oid],MAX_TEXT,budget?budget():{signal})).toString('utf8').trim();result.largeEligible=/^\d+$/.test(size)&&Number(size)<=MAX_LARGE_TEXT;}
705
+ if(mode==='120000')result.largeEligible=false;
701
706
  if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
702
707
  return result;
703
708
  }
@@ -709,7 +714,7 @@ export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,l
709
714
  if(!entry)throw new Error('Historical change is not part of selected commit and parent');
710
715
  const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal,large):{text:''};
711
716
  const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal,large):{text:''};
712
- return {...entry,large,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
717
+ return withComparisonPreview({...entry,large,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')},{allowLarge:true});
713
718
  }
714
719
  export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
715
720
  if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
@@ -719,7 +724,7 @@ export async function getRevisionComparison(repo,{base,target,id,signal,large=fa
719
724
  if(!entry)throw new Error('Change is not part of selected revision pair');
720
725
  const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal,large):{text:''};
721
726
  const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal,large):{text:''};
722
- return {...entry,base,target,large,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
727
+ return withComparisonPreview({...entry,base,target,large,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')},{allowLarge:true});
723
728
  }
724
729
  async function properties(root, file, base, signal) {
725
730
  signal?.throwIfAborted();
@@ -735,7 +740,7 @@ async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
735
740
  repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);assertGit(repo,'Large workspace comparison supports Git only');
736
741
  const run=args=>git(repo.root,args,MAX_TEXT,options());
737
742
  const status=()=>changes(repo,mode,options());
738
- const entry=(await status()).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status');
743
+ const entry=(await status()).find(row=>row.id===id);if(!entry)throw selectionChanged('Change no longer exists; refresh repository status');
739
744
  if(entry.status==='conflicted')throw Error('Large conflicted comparisons are not supported');
740
745
  const head=()=>resolveHeadCommit(run);
741
746
  const revision=await head(),indexArgs=['ls-files','--stage','-z','--',entry.path],index=await run(indexArgs);
@@ -751,11 +756,11 @@ async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
751
756
  if(mode==='staged'){if(entry.indexStatus!=='D')right=await readIndex();}
752
757
  else if(entry.worktreeStatus!=='D'){
753
758
  try{const result=await readWorkspaceLargeText(repo.root,entry.path,signal,options);right=result.value;verify=result.verify;}
754
- catch(error){if(error.code!=='ENOENT'||entry.indexStatus!=='D')throw error;verify=async()=>{const target=await confined(repo.root,entry.path);try{await fs.lstat(target);throw Error('Working file appeared; refresh repository status');}catch(missing){if(missing.code!=='ENOENT')throw missing;}};await verify();}
759
+ catch(error){if(error.code!=='ENOENT'||entry.indexStatus!=='D')throw error;verify=async()=>{const target=await confined(repo.root,entry.path);try{await fs.lstat(target);throw selectionChanged('Working file appeared; refresh repository status');}catch(missing){if(missing.code!=='ENOENT')throw missing;}};await verify();}
755
760
  }
756
- options();if(revision!==await head()||!index.equals(await run(indexArgs))||!(await status()).some(row=>row.id===id))throw Error('Workspace selection changed; refresh repository status');
761
+ options();if(revision!==await head()||!index.equals(await run(indexArgs))||!(await status()).some(row=>row.id===id))throw selectionChanged('Workspace selection changed; refresh repository status');
757
762
  await verify?.();options();
758
- return {...entry,large:true,left:{...left,label:mode==='unstaged'?'Index':'HEAD'},right:{...right,label:mode==='staged'?'Index':'Working tree'}};
763
+ return withComparisonPreview({...entry,large:true,left:{...left,label:mode==='unstaged'?'Index':'HEAD'},right:{...right,label:mode==='staged'?'Index':'Working tree'}});
759
764
  }
760
765
 
761
766
  /** Explicit Index stage snapshot; never reads the selected working file or submodule. */
@@ -763,10 +768,10 @@ export async function getConflictStages(repo,{mode='all',id,signal}={}){
763
768
  const deadline=Date.now()+TIMEOUT,budget=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Conflict stage read timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
764
769
  if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid conflict change id');
765
770
  repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);assertGit(repo,'Conflict stages support Git only');
766
- const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw Error('Selected conflict no longer exists; refresh status.');
771
+ const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw selectionChanged('Selected conflict no longer exists; refresh status.');
767
772
  await confined(repo.root,entry.path);
768
773
  const list=()=>git(repo.root,['ls-files','--unmerged','-z','--',entry.path],MAX_TEXT,budget());
769
- const before=await list(),metadata=parseConflictStages(before,entry.path);if(!metadata.length)throw Error('Conflict stages no longer exist; refresh status.');
774
+ const before=await list(),metadata=parseConflictStages(before,entry.path);if(!metadata.length)throw selectionChanged('Conflict stages no longer exist; refresh status.');
770
775
  const stages=[];
771
776
  for(const stage of [1,2,3]){
772
777
  const record=metadata.find(row=>row.stage===stage);if(!record){stages.push({stage,present:false,text:''});continue;}
@@ -774,52 +779,96 @@ export async function getConflictStages(repo,{mode='all',id,signal}={}){
774
779
  else{body=await content(()=>git(repo.root,['cat-file','blob',record.oid],MAX_TEXT,budget()));if(record.kind==='symlink')body.notice=[body.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');}
775
780
  stages.push({...record,present:true,...body});
776
781
  }
777
- if(!(await list()).equals(before))throw Error('Conflict Index changed while reading; refresh status.');
778
- if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw Error('Selected conflict changed while reading; refresh status.');
782
+ if(!(await list()).equals(before))throw selectionChanged('Conflict Index changed while reading; refresh status.');
783
+ if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw selectionChanged('Selected conflict changed while reading; refresh status.');
779
784
  await confined(repo.root,entry.path);budget();
780
785
  return {id,path:entry.path,stages,snapshot:createHash('sha256').update(before).digest('hex')};
781
786
  }
782
787
 
783
- /** Revalidates the opaque ID against fresh status before reading any selected path. */
784
- export async function getComparison(repo, { mode = 'all', id, large=false, signal } = {}) {
785
- if(typeof large!=='boolean')throw Error('Invalid large comparison mode');
786
- if(large)return getWorkspaceLargeComparison(repo,{mode,id,signal});
788
+ // SVN emits equivalent XML attributes in different orders; compare semantic fields, not wire bytes.
789
+ function stableSnapshot(value) {
790
+ const canonical = item => Array.isArray(item) ? item.map(canonical) : item && typeof item === 'object' ? Object.fromEntries(Object.keys(item).sort().map(key => [key,canonical(item[key])])) : item;
791
+ return JSON.stringify(canonical(value));
792
+ }
793
+ const svnInfoSnapshot = buffer => stableSnapshot(parseXML(buffer));
794
+
795
+ /** Snapshot selected file metadata without following a symlink or entering a submodule. */
796
+ async function workingIdentity(root, file) {
797
+ const target = await confined(root, file);
798
+ try {
799
+ const stat = await fs.lstat(target,{bigint:true});
800
+ return [stat.dev,stat.ino,stat.mode,stat.size,stat.mtimeNs,stat.ctimeNs,stat.isSymbolicLink() ? await fs.readlink(target) : ''].map(String).join(':');
801
+ } catch (error) {if(error.code === 'ENOENT') return null;throw error;}
802
+ }
803
+
804
+ /** Bind mutable HEAD/Index to immutable objects, then reject any changed selection. */
805
+ export async function getComparison(repo, {mode = 'all',id,large = false,signal} = {}) {
806
+ if(typeof large !== 'boolean') throw Error('Invalid large comparison mode');
807
+ if(large) return getWorkspaceLargeComparison(repo,{mode,id,signal});
787
808
  signal?.throwIfAborted();
788
- repo = await checkedRepo(repo,signal); modeFor(repo, mode);
789
- const entry = (await changes(repo, mode,{signal})).find(item => item.id === id);
790
- if (!entry) throw new Error('Change no longer exists; refresh repository status.');
791
- await confined(repo.root, entry.path); if (entry.oldPath) await confined(repo.root, entry.oldPath);
792
- let left = { text: '' }, right = { text: '' }, leftLabel, rightLabel, props, notice;
793
- if (repo.type === 'git') {
794
- leftLabel = mode === 'unstaged' ? 'Index' : 'HEAD'; rightLabel = mode === 'staged' ? 'Index' : 'Working tree';
795
- if (entry.status === 'conflicted') {
796
- notice = 'Unmerged conflict: showing HEAD versus working tree (index has conflict stages).'; leftLabel = 'HEAD'; rightLabel = 'Working tree';
797
- try { left = await gitBlob(repo.root, 'HEAD', entry.oldPath || entry.path,{signal}); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; }
798
- right = await working(repo.root, entry.path, false, signal);
809
+ repo = await checkedRepo(repo,signal);modeFor(repo,mode);
810
+ const status = () => changes(repo,mode,{signal});
811
+ const entry = (await status()).find(item => item.id === id);
812
+ if(!entry) throw selectionChanged('Change no longer exists; refresh repository status.');
813
+ await confined(repo.root,entry.path);
814
+ if(entry.oldPath) await confined(repo.root,entry.oldPath);
815
+ const readsWorking = repo.type === 'svn' || mode !== 'staged' || entry.status === 'conflicted';
816
+ const workBefore = readsWorking ? await workingIdentity(repo.root,entry.path) : undefined;
817
+ let left = {text:''},right = {text:''},leftLabel,rightLabel,props,notice,verify;
818
+ if(repo.type === 'git') {
819
+ const run = args => git(repo.root,args,MAX_TEXT,{signal});
820
+ const head = () => resolveHeadCommit(run);
821
+ const revision = await head();
822
+ const indexArgs = ['ls-files','--stage','-z','--',entry.path];
823
+ const index = await run(indexArgs);
824
+ verify = async () => {
825
+ if(revision !== await head() || !index.equals(await run(indexArgs))) throw selectionChanged();
826
+ };
827
+ leftLabel = mode === 'unstaged' ? 'Index' : 'HEAD';
828
+ rightLabel = mode === 'staged' ? 'Index' : 'Working tree';
829
+ if(entry.status === 'conflicted') {
830
+ notice = 'Unmerged conflict: showing HEAD versus working tree (index has conflict stages).';
831
+ leftLabel = 'HEAD';rightLabel = 'Working tree';
832
+ if(revision) left = await gitBlob(repo.root,revision,entry.oldPath || entry.path,{signal});
833
+ right = await working(repo.root,entry.path,false,signal);
799
834
  } else {
800
- let hasHead = true; try { await git(repo.root, ['rev-parse', '--verify', 'HEAD'], undefined, {signal}); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; hasHead = false; }
801
835
  const untracked = entry.indexStatus === '?';
802
836
  const addedToHead = entry.indexStatus === 'A' || entry.indexStatus === 'C';
803
- if (!untracked && (mode === 'unstaged' ? entry.indexStatus !== 'D' : (hasHead && !addedToHead))) left = await gitBlob(repo.root, mode === 'unstaged' ? '' : 'HEAD', mode === 'unstaged' ? entry.path : entry.oldPath || entry.path,{signal});
804
- if (mode === 'staged') { if (entry.indexStatus !== 'D') right = await gitBlob(repo.root, '', entry.path,{signal}); }
805
- else right = await working(repo.root, entry.path, false, signal);
837
+ if(!untracked && (mode === 'unstaged' ? entry.indexStatus !== 'D' : revision && !addedToHead)) {
838
+ left = await gitBlob(repo.root,mode === 'unstaged' ? '' : revision,mode === 'unstaged' ? entry.path : entry.oldPath || entry.path,{signal},index);
839
+ }
840
+ if(mode === 'staged') {if(entry.indexStatus !== 'D') right = await gitBlob(repo.root,'',entry.path,{signal},index);}
841
+ else right = await working(repo.root,entry.path,false,signal);
806
842
  }
807
843
  } else {
808
- leftLabel = 'BASE'; rightLabel = 'Working copy';
809
- const added = ['added', 'unversioned', 'replaced'].includes(entry.status);
810
- if (entry.status === 'replaced') notice = 'Replaced node: local SVN BASE is unavailable; showing empty versus working content without contacting the server.';
844
+ leftLabel = 'BASE';rightLabel = 'Working copy';
845
+ const added = ['added','unversioned','replaced'].includes(entry.status);
846
+ const info = () => svn(repo.root,['info','--xml','--',entry.path + '@'],undefined,{signal});
847
+ const before = entry.status === 'unversioned' ? null : await info();
848
+ verify = async () => {
849
+ if(before && svnInfoSnapshot(before) !== svnInfoSnapshot(await info())) throw selectionChanged('SVN BASE selection changed; refresh repository status');
850
+ if(props) {
851
+ const after = {left:added ? {} : await properties(repo.root,entry.path,true,signal),right:['deleted','missing'].includes(entry.status) ? {} : await properties(repo.root,entry.path,false,signal)};
852
+ if(stableSnapshot(after) !== stableSnapshot(props)) throw selectionChanged('SVN properties changed; refresh repository status');
853
+ }
854
+ };
855
+ if(entry.status === 'replaced') notice = 'Replaced node: local SVN BASE is unavailable; showing empty versus working content without contacting the server.';
811
856
  let directory = false;
812
- try { directory = (await fs.lstat(await confined(repo.root, entry.path))).isDirectory(); } catch (e) { if (e.code !== 'ENOENT') throw e; }
813
- if (!added && !directory) {
814
- const info = parseXML(await svn(repo.root, ['info', '--xml', '--', entry.path + '@'], undefined, {signal})).info.entry[0]; directory = info.kind === 'dir';
815
- if (!directory) left = await content(() => svn(repo.root, ['cat', '--revision', 'BASE', '--', entry.path + '@'], MAX_TEXT, {signal}));
857
+ try {directory = (await fs.lstat(await confined(repo.root,entry.path))).isDirectory();} catch(error) {if(error.code !== 'ENOENT') throw error;}
858
+ if(!added && !directory) {
859
+ directory = parseXML(before).info.entry[0].kind === 'dir';
860
+ if(!directory) left = await content(() => svn(repo.root,['cat','--revision','BASE','--',entry.path + '@'],MAX_TEXT,{signal}));
816
861
  }
817
- if (directory) notice = 'Directory change; property differences are shown separately.';
818
- else right = await working(repo.root, entry.path, true, signal);
819
- if (entry.status !== 'unversioned') props = { left: added ? {} : await properties(repo.root, entry.path, true, signal), right: ['deleted', 'missing'].includes(entry.status) ? {} : await properties(repo.root, entry.path, false, signal) };
820
- if (entry.status === 'conflicted') notice = 'Conflicted working copy: showing BASE versus working content.';
862
+ if(directory) {notice = 'Directory change; property differences are shown separately.';right = {text:'',previewReason:'unsupported-type',largeEligible:false};}
863
+ else right = await working(repo.root,entry.path,true,signal);
864
+ if(entry.status !== 'unversioned') props = {left:added ? {} : await properties(repo.root,entry.path,true,signal),right:['deleted','missing'].includes(entry.status) ? {} : await properties(repo.root,entry.path,false,signal)};
865
+ if(entry.status === 'conflicted') notice = 'Conflicted working copy: showing BASE versus working content.';
821
866
  }
867
+ await verify();
868
+ if(!(await status()).some(row => row.id === id)) throw selectionChanged();
869
+ if(readsWorking && workBefore !== await workingIdentity(repo.root,entry.path)) throw selectionChanged('Working selection changed; refresh repository status');
822
870
  signal?.throwIfAborted();
823
- if (entry.submodule) notice = [notice, `Submodule pointer ${entry.submodule.recorded} (checkout: ${entry.submodule.checkout}); nested commit and contents are not loaded by this preview; Git status may inspect nested state.`].filter(Boolean).join(' ');
824
- return { ...entry, left: { label: leftLabel, text: left.text, encoding: left.encoding }, right: { label: rightLabel, text: right.text, encoding: right.encoding }, ...(left.binary || right.binary ? { binary: true } : {}), ...([notice, left.notice, right.notice].filter(Boolean).length ? { notice: [...new Set([notice, left.notice, right.notice].filter(Boolean))].join(' ') } : {}), ...(props ? { properties: props } : {}) };
871
+ if(entry.submodule) notice = [notice,`Submodule pointer ${entry.submodule.recorded} (checkout: ${entry.submodule.checkout}); nested commit and contents are not loaded by this preview; Git status may inspect nested state.`].filter(Boolean).join(' ');
872
+ const result = {...entry,left:{label:leftLabel,text:left.text,encoding:left.encoding},right:{label:rightLabel,text:right.text,encoding:right.encoding},...(left.binary || right.binary ? {binary:true} : {}),...([notice,left.notice,right.notice].filter(Boolean).length ? {notice:[...new Set([notice,left.notice,right.notice].filter(Boolean))].join(' ')} : {}),...(props ? {properties:props} : {})};
873
+ return withComparisonPreview(result,{left,right,allowLarge:repo.type === 'git',conflicted:entry.status === 'conflicted'});
825
874
  }
@@ -3,6 +3,7 @@ import {constants} from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {confined} from './repository-path.mjs';
5
5
  import {MAX_LARGE_TEXT,decodeLargeText} from './text-content.mjs';
6
+ const changed=message=>Object.assign(Error(message),{code:'vcs/selection-changed'});
6
7
  const fingerprint=s=>[s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(':');
7
8
 
8
9
  /** Read complete bounded text, retaining a final named-path verification. No writes. */
@@ -19,13 +20,13 @@ export async function readWorkspaceLargeText(root,file,signal,budget){
19
20
  const target=await resolve(),before=await fs.lstat(target,{bigint:true});
20
21
  if(!before.isFile())throw Error('Large workspace comparison requires a regular file');
21
22
  if(before.size>BigInt(MAX_LARGE_TEXT))throw Error('Large comparison exceeds 8 MiB per side');
22
- const verify=async()=>{await resolve();const named=await fs.lstat(target,{bigint:true});if(!named.isFile()||named.isSymbolicLink()||fingerprint(named)!==fingerprint(before))throw Error('Working file changed; refresh repository status');check();};
23
+ const verify=async()=>{await resolve();const named=await fs.lstat(target,{bigint:true});if(!named.isFile()||named.isSymbolicLink()||fingerprint(named)!==fingerprint(before))throw changed('Working file changed; refresh repository status');check();};
23
24
  const handle=await fs.open(target,constants.O_RDONLY|(constants.O_NOFOLLOW||0));
24
25
  try{
25
- const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working file changed during open');
26
+ const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw changed('Working file changed during open');
26
27
  const buffer=Buffer.alloc(Number(before.size)+1);let offset=0;
27
28
  while(offset<buffer.length){check();const result=await handle.read(buffer,offset,buffer.length-offset,offset);if(!result.bytesRead)break;offset+=result.bytesRead;}
28
- check();if(offset!==Number(before.size)||fingerprint(await handle.stat({bigint:true}))!==fingerprint(before))throw Error('Working file changed during read');
29
+ check();if(offset!==Number(before.size)||fingerprint(await handle.stat({bigint:true}))!==fingerprint(before))throw changed('Working file changed during read');
29
30
  await verify();const value=decodeLargeText(buffer.subarray(0,offset),signal);await verify();
30
31
  return {value,verify};
31
32
  }finally{await handle.close();}