@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/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 boundary=row.parents.length>1?'merge-first-parent':(/^\nC/.test(status)||(/^\nR/.test(status)&&status!=='\nR100'))?'copy-or-inexact-rename':null;
113
- rows.push({...row,path:current,...(boundary?{boundary}:status==='\nR100'?{oldPath}:{})});
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(status==='\nR100')current=oldPath;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
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
 
@@ -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 output=await git(repo.root,['--literal-pathspecs','log','-z','--follow','--first-parent','--diff-merges=first-parent','--find-renames=100%','--name-status','--no-ext-diff','--no-textconv','--no-show-signature','--encoding=UTF-8','--max-count='+count,'--format='+HISTORY_FORMAT,commit,'--',entry.path],MAX_TEXT,{signal});
391
- const rows=parseFollowHistory(output.toString('utf8'),entry.path,count).slice(offset);
392
- return {...historyPage(rows,commit,offset,limit),path:entry.path,followsRenames:true,followPolicy:'exact-first-parent'};
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 + '@']));