@feng0531/dsh-vcs 0.4.10 → 0.6.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/README.md +20 -8
- package/branch-switch-host.mjs +35 -0
- package/branch-switch.mjs +193 -0
- package/dist/client.js +3 -3
- package/dist/editor.js +59 -59
- package/dist/frontend.json +1 -1
- package/dist/interface.js +3 -3
- package/dist/svn-host.mjs +1 -1
- package/docs/RELEASING.md +41 -9
- package/index.mjs +58 -10
- package/local-command.mjs +58 -25
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +14 -4
- package/src/commit-signature.mjs +26 -13
- package/src/historical-access.mjs +139 -0
- package/src/operation-budget.mjs +44 -0
- package/src/preview-capabilities.mjs +19 -0
- package/vcs.mjs +140 -91
- package/workspace-large-text.mjs +4 -3
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
|
-
|
|
348
|
-
|
|
349
|
-
repo=await
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
if(
|
|
359
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
647
|
-
if (!stat.isFile()) return { text: '', notice:
|
|
648
|
-
if (stat.size > MAX_TEXT) return { text: '', notice:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
778
|
-
if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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);
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (repo.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
804
|
-
|
|
805
|
-
|
|
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';
|
|
809
|
-
const added = ['added',
|
|
810
|
-
|
|
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 {
|
|
813
|
-
if
|
|
814
|
-
|
|
815
|
-
if
|
|
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
|
|
818
|
-
else right = await working(repo.root,
|
|
819
|
-
if
|
|
820
|
-
if
|
|
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
|
|
824
|
-
|
|
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
|
}
|
package/workspace-large-text.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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();}
|