@feng0531/dsh-vcs 0.3.68 → 0.3.69

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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.68",
3
+ "version": "0.3.69",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
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
 
@@ -455,35 +455,48 @@ async function working(root, relative, svnLink = false) {
455
455
  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
456
  }
457
457
  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){
458
+ async function historicalBlob(root,revision,file,signal,large=false){
459
459
  const listing=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
460
460
  const records=listing.split(String.fromCharCode(0)).filter(Boolean);
461
461
  const record=records.find(row=>row.slice(row.indexOf(' ')+1)===file);
462
462
  if(!record)throw new Error('Historical path missing');
463
463
  const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
464
+ if(large&&(!['100644','100755'].includes(mode)||type!=='blob'))throw new Error('Large comparison requires a committed regular file.');
464
465
  if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
465
466
  if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
467
+ if (large) {
468
+ const rawSize = (await git(root, ['cat-file', '-s', oid], MAX_TEXT, {signal})).toString('utf8').trim();
469
+ if (!/^\d+$/.test(rawSize)) throw new Error('Invalid historical object size.');
470
+ const size = Number(rawSize);
471
+ if (size > MAX_LARGE_TEXT) throw new Error('Large comparison exceeds 8 MiB per side.');
472
+ const buffer = await git(root, ['cat-file', 'blob', oid], MAX_LARGE_TEXT, {signal});
473
+ if (buffer.length !== size) throw new Error('Historical object size changed.');
474
+ return decodeLargeText(buffer, signal);
475
+ }
476
+
466
477
  const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
467
478
  if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
468
479
  return result;
469
480
  }
470
- export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal}={}){
481
+ export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
482
+ if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
471
483
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
472
484
  const details=await commitDetails(repo,{commit,parentIndex,signal});
473
485
  const entry=details.changes.find(row=>row.id===id);
474
486
  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(' ')};
487
+ const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal,large):{text:''};
488
+ const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal,large):{text:''};
489
+ 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
490
  }
479
- export async function getRevisionComparison(repo,{base,target,id,signal}={}){
491
+ export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
492
+ if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
480
493
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid revision change id');
481
494
  const details=await getRevisionChanges(repo,{base,target,signal});
482
495
  const entry=details.changes.find(row=>row.id===id);
483
496
  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(' ')};
497
+ const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal,large):{text:''};
498
+ const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal,large):{text:''};
499
+ 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
500
  }
488
501
  async function properties(root, file, base) {
489
502
  const result = parseXML(await svn(root, ['proplist', '--xml', '--verbose', ...(base ? ['--revision', 'BASE'] : []), '--', file + '@']));