@feng0531/dsh-vcs 0.3.68 → 0.3.70
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 +4 -3
- package/dist/client.js +3 -3
- package/dist/editor.js +87 -87
- package/git-history.mjs +6 -4
- package/index.mjs +6 -5
- package/package.json +1 -1
- package/text-content.mjs +24 -0
- package/vcs.mjs +44 -14
package/git-history.mjs
CHANGED
|
@@ -97,7 +97,7 @@ export function historyPage(rows,snapshot,offset,limit){
|
|
|
97
97
|
return {snapshot,commits:rows.slice(0,limit),nextOffset:hasMore&&!truncated?offset+limit:null,truncated};
|
|
98
98
|
}
|
|
99
99
|
// --follow emits one selected-path name-status record per commit. Fail closed on unexpected framing.
|
|
100
|
-
export function parseFollowHistory(text,path,maxRecords){
|
|
100
|
+
export function parseFollowHistory(text,path,maxRecords,{similarity=false}={}){
|
|
101
101
|
if(typeof text!=='string'||Buffer.byteLength(text,'utf8')>2*1024*1024||!Number.isInteger(maxRecords)||maxRecords<1||maxRecords>10101)throw Error('Invalid follow history bounds');
|
|
102
102
|
if(!text)return [];if(!text.endsWith('\0'))throw Error('Truncated follow history');
|
|
103
103
|
const fields=text.slice(0,-1).split('\0'),rows=[];let current=path;
|
|
@@ -109,10 +109,12 @@ export function parseFollowHistory(text,path,maxRecords){
|
|
|
109
109
|
const oldPath=fields[i++],newPath=/^\n[RC]/.test(status)?fields[i++]:oldPath;
|
|
110
110
|
if(!oldPath||!newPath||newPath!==current)throw Error('Ambiguous follow history path');
|
|
111
111
|
if(/^\n[RC]/.test(status)&&Number(status.slice(2))>100)throw Error('Invalid follow similarity score');
|
|
112
|
-
const
|
|
113
|
-
|
|
112
|
+
const rename=/^\nR/.test(status),score=rename?Number(status.slice(2)):null;
|
|
113
|
+
const accepted=rename&&(score===100||(similarity&&score>=50));
|
|
114
|
+
const boundary=row.parents.length>1?'merge-first-parent':(/^\nC/.test(status)||(rename&&!accepted))?'copy-or-inexact-rename':null;
|
|
115
|
+
rows.push({...row,path:current,...(boundary?{boundary}:accepted?{oldPath,...(similarity?{similarity:score}:{})}:{})});
|
|
114
116
|
if(boundary)break;
|
|
115
|
-
if(
|
|
117
|
+
if(accepted)current=oldPath;
|
|
116
118
|
if(status==='\nA')break; // Stop at creation; never cross an older unrelated same-name file.
|
|
117
119
|
}
|
|
118
120
|
return rows;
|
package/index.mjs
CHANGED
|
@@ -34,11 +34,11 @@ const REQUEST_FIELDS = {
|
|
|
34
34
|
'vcs/compare': ['sessionId', 'repositoryId', 'mode', 'id'],
|
|
35
35
|
'vcs/history': ['sessionId', 'repositoryId', 'snapshot', 'offset', 'limit', 'search'],
|
|
36
36
|
'vcs/commit': ['sessionId', 'repositoryId', 'commit', 'parentIndex'],
|
|
37
|
-
'vcs/commit-compare': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id'],
|
|
37
|
+
'vcs/commit-compare': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'large'],
|
|
38
38
|
'vcs/file-history': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'offset', 'limit', 'follow'],
|
|
39
39
|
'vcs/blame': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'startLine', 'lineLimit'],
|
|
40
40
|
'vcs/revision-changes': ['sessionId', 'repositoryId', 'base', 'target'],
|
|
41
|
-
'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id'],
|
|
41
|
+
'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'large'],
|
|
42
42
|
'vcs/revision-image': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'side'],
|
|
43
43
|
'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
|
|
44
44
|
'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
|
|
@@ -68,6 +68,7 @@ function validatePayload(endpoint, payload) {
|
|
|
68
68
|
if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
|
|
69
69
|
if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
|
|
70
70
|
}
|
|
71
|
+
if(['vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
|
|
71
72
|
if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
|
|
72
73
|
if(endpoint==='vcs/blame'&&((payload.startLine!==undefined&&(!Number.isInteger(payload.startLine)||payload.startLine<1||payload.startLine>100001))||(payload.lineLimit!==undefined&&(!Number.isInteger(payload.lineLimit)||payload.lineLimit<1||payload.lineLimit>500))))throw invalid('Invalid blame line window.');
|
|
73
74
|
if(endpoint==='vcs/file-history'&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
|
|
@@ -111,9 +112,9 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
111
112
|
case 'vcs/revision-changes':
|
|
112
113
|
return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
|
|
113
114
|
case 'vcs/revision-compare':
|
|
114
|
-
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, signal});
|
|
115
|
+
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
|
|
115
116
|
case 'vcs/commit-compare':
|
|
116
|
-
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, signal});
|
|
117
|
+
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
|
|
117
118
|
case 'vcs/commit':
|
|
118
119
|
return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
|
|
119
120
|
case 'vcs/history':
|
|
@@ -194,7 +195,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
194
195
|
// Both adapter operations revalidate the canonical root; comparison also validates
|
|
195
196
|
// the change ID against fresh status, so do not duplicate a full status scan here.
|
|
196
197
|
if(['vcs/history','vcs/commit','vcs/commit-compare','vcs/revision-changes','vcs/revision-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/commit-image','vcs/revision-image','vcs/workspace-image','vcs/references'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
|
|
197
|
-
if(endpoint==='vcs/tree-segment'){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
|
|
198
|
+
if(endpoint==='vcs/tree-segment'||(['vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large===true)){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
|
|
198
199
|
if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)){
|
|
199
200
|
if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
|
|
200
201
|
activeImages++;imageSlot=true;
|
package/package.json
CHANGED
package/text-content.mjs
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
/** Pure bounded byte decoding shared by working and committed previews. */
|
|
2
2
|
export const MAX_TEXT = 2 * 1024 * 1024;
|
|
3
|
+
export const MAX_LARGE_TEXT = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
/** Explicit historical mode only: complete UTF-8, never a truncated Diff input. */
|
|
6
|
+
export function decodeLargeText(buffer, signal) {
|
|
7
|
+
signal?.throwIfAborted();
|
|
8
|
+
if (buffer.length > MAX_LARGE_TEXT) throw new Error('Large comparison exceeds 8 MiB per side.');
|
|
9
|
+
if (buffer.some(byte => byte < 32 && ![9, 10, 12, 13].includes(byte))) {
|
|
10
|
+
throw new Error('Large comparison requires UTF-8 text without binary control bytes.');
|
|
11
|
+
}
|
|
12
|
+
let text;
|
|
13
|
+
try { text = new TextDecoder('utf-8', {fatal: true}).decode(buffer); }
|
|
14
|
+
catch { throw new Error('Large comparison requires valid UTF-8 text.'); }
|
|
15
|
+
let lines = 1, length = 0;
|
|
16
|
+
for (let index = 0; index < text.length; index++) {
|
|
17
|
+
if (index % 65536 === 0) signal?.throwIfAborted();
|
|
18
|
+
if (text[index] === '\n' || text[index] === '\r') {
|
|
19
|
+
if (text[index] === '\r' && text[index + 1] === '\n') index++;
|
|
20
|
+
if (++lines > 100000) throw new Error('Large comparison exceeds 100000 lines per side.');
|
|
21
|
+
length = 0;
|
|
22
|
+
} else if (++length > 65536) throw new Error('Large comparison exceeds 65536 characters per line.');
|
|
23
|
+
}
|
|
24
|
+
signal?.throwIfAborted();
|
|
25
|
+
return {text, encoding: 'UTF-8', totalBytes: buffer.length, lineCount: lines};
|
|
26
|
+
}
|
|
3
27
|
|
|
4
28
|
export function decode(buffer) {
|
|
5
29
|
if (buffer.length > MAX_TEXT) {
|
package/vcs.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
10
10
|
|
|
11
|
-
import {decode, MAX_TEXT} from './text-content.mjs';
|
|
11
|
+
import {decode, MAX_TEXT, MAX_LARGE_TEXT, decodeLargeText} from './text-content.mjs';
|
|
12
12
|
import {inside, confined} from './repository-path.mjs';
|
|
13
13
|
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
14
14
|
|
|
@@ -386,10 +386,27 @@ export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,lim
|
|
|
386
386
|
const entry=details.changes.find(row=>row.id===id);
|
|
387
387
|
if(!entry)throw new Error('Change is not part of selected commit');
|
|
388
388
|
if(follow){
|
|
389
|
-
const count=offset+limit+1;
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
389
|
+
const count=offset+limit+1,deadline=Date.now()+TIMEOUT;
|
|
390
|
+
const followOptions=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Rename tracing timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
|
|
391
|
+
const output=await git(repo.root,['--literal-pathspecs','log','-z','--follow','--first-parent','--diff-merges=first-parent','--find-renames=50%','-l100','--name-status','--no-ext-diff','--no-textconv','--no-show-signature','--encoding=UTF-8','--max-count='+count,'--format='+HISTORY_FORMAT,commit,'--',entry.path],MAX_TEXT,followOptions());
|
|
392
|
+
const chain=parseFollowHistory(output.toString('utf8'),entry.path,count,{similarity:true});
|
|
393
|
+
let checks=0;
|
|
394
|
+
for(let index=0;index<chain.length;index++){
|
|
395
|
+
const row=chain[index];if(!row.oldPath)continue;
|
|
396
|
+
let boundary;
|
|
397
|
+
if(++checks>32)boundary='rename-check-limit';
|
|
398
|
+
else {
|
|
399
|
+
// Confirm the whole transition, not Git's selected-path heuristic alone.
|
|
400
|
+
// A single deletion and addition excludes competing deleted sources.
|
|
401
|
+
const raw=await git(repo.root,['diff-tree','--no-commit-id','--name-status','-z','-r','--no-renames','--no-ext-diff','--no-textconv',row.parents[0],row.id,'--'],MAX_TEXT,followOptions());
|
|
402
|
+
const changes=parseCommitChanges(raw.toString('utf8'),row.id);
|
|
403
|
+
const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
|
|
404
|
+
if(removed.length!==1||added.length!==1||removed[0].path!==row.oldPath||added[0].path!==row.path)boundary='ambiguous-rename';
|
|
405
|
+
}
|
|
406
|
+
if(boundary){chain[index]={...row,boundary};chain.length=index+1;break;}
|
|
407
|
+
}
|
|
408
|
+
const rows=chain.slice(offset);
|
|
409
|
+
return {...historyPage(rows,commit,offset,limit),path:entry.path,followsRenames:true,followPolicy:'unique-similarity-first-parent'};
|
|
393
410
|
}
|
|
394
411
|
const text=await git(repo.root,['--literal-pathspecs','log','-z','--no-show-signature','--encoding=UTF-8','--topo-order','--max-count='+String(limit+1),'--skip='+String(offset),'--format='+HISTORY_FORMAT,commit,'--',entry.path],MAX_TEXT,{signal});
|
|
395
412
|
return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
|
|
@@ -455,35 +472,48 @@ async function working(root, relative, svnLink = false) {
|
|
|
455
472
|
try { const info = await handle.stat(); if (!info.isFile()) throw new Error('Not a regular file'); const buffer = Buffer.alloc(MAX_TEXT + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); await confined(root, relative); return decode(buffer.subarray(0, bytesRead)); } finally { await handle.close(); }
|
|
456
473
|
}
|
|
457
474
|
async function gitBlob(root, revision, file) { return content(() => git(root, ['show', '--no-ext-diff', '--no-textconv', revision + ':' + file], MAX_TEXT)); }
|
|
458
|
-
async function historicalBlob(root,revision,file,signal){
|
|
475
|
+
async function historicalBlob(root,revision,file,signal,large=false){
|
|
459
476
|
const listing=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
|
|
460
477
|
const records=listing.split(String.fromCharCode(0)).filter(Boolean);
|
|
461
478
|
const record=records.find(row=>row.slice(row.indexOf(' ')+1)===file);
|
|
462
479
|
if(!record)throw new Error('Historical path missing');
|
|
463
480
|
const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
|
|
481
|
+
if(large&&(!['100644','100755'].includes(mode)||type!=='blob'))throw new Error('Large comparison requires a committed regular file.');
|
|
464
482
|
if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
|
|
465
483
|
if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
|
|
484
|
+
if (large) {
|
|
485
|
+
const rawSize = (await git(root, ['cat-file', '-s', oid], MAX_TEXT, {signal})).toString('utf8').trim();
|
|
486
|
+
if (!/^\d+$/.test(rawSize)) throw new Error('Invalid historical object size.');
|
|
487
|
+
const size = Number(rawSize);
|
|
488
|
+
if (size > MAX_LARGE_TEXT) throw new Error('Large comparison exceeds 8 MiB per side.');
|
|
489
|
+
const buffer = await git(root, ['cat-file', 'blob', oid], MAX_LARGE_TEXT, {signal});
|
|
490
|
+
if (buffer.length !== size) throw new Error('Historical object size changed.');
|
|
491
|
+
return decodeLargeText(buffer, signal);
|
|
492
|
+
}
|
|
493
|
+
|
|
466
494
|
const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
|
|
467
495
|
if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
|
|
468
496
|
return result;
|
|
469
497
|
}
|
|
470
|
-
export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal}={}){
|
|
498
|
+
export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
|
|
499
|
+
if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
|
|
471
500
|
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
472
501
|
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
473
502
|
const entry=details.changes.find(row=>row.id===id);
|
|
474
503
|
if(!entry)throw new Error('Historical change is not part of selected commit and parent');
|
|
475
|
-
const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal):{text:''};
|
|
476
|
-
const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal):{text:''};
|
|
477
|
-
return {...entry,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
|
|
504
|
+
const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal,large):{text:''};
|
|
505
|
+
const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal,large):{text:''};
|
|
506
|
+
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(' ')};
|
|
478
507
|
}
|
|
479
|
-
export async function getRevisionComparison(repo,{base,target,id,signal}={}){
|
|
508
|
+
export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
|
|
509
|
+
if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
|
|
480
510
|
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid revision change id');
|
|
481
511
|
const details=await getRevisionChanges(repo,{base,target,signal});
|
|
482
512
|
const entry=details.changes.find(row=>row.id===id);
|
|
483
513
|
if(!entry)throw new Error('Change is not part of selected revision pair');
|
|
484
|
-
const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal):{text:''};
|
|
485
|
-
const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal):{text:''};
|
|
486
|
-
return {...entry,base,target,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
|
|
514
|
+
const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal,large):{text:''};
|
|
515
|
+
const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal,large):{text:''};
|
|
516
|
+
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(' ')};
|
|
487
517
|
}
|
|
488
518
|
async function properties(root, file, base) {
|
|
489
519
|
const result = parseXML(await svn(root, ['proplist', '--xml', '--verbose', ...(base ? ['--revision', 'BASE'] : []), '--', file + '@']));
|