@feng0531/dsh-vcs 0.3.56 → 0.3.57

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
@@ -15,6 +15,17 @@ export function historySearchArgs(search){
15
15
  return {args,paths};
16
16
  }
17
17
  // Each line-porcelain record carries its own metadata; never interpret filename as an access path.
18
+ export function decodeBlamePath(value){
19
+ if(!value.startsWith('"'))return value;
20
+ if(!value.endsWith('"'))throw Error('Invalid quoted blame path');
21
+ const bytes=[];const escapes={a:7,b:8,t:9,n:10,v:11,f:12,r:13,'"':34,'\\':92};
22
+ for(let i=1;i<value.length-1;i++){
23
+ if(value[i]!=='\\'){bytes.push(...Buffer.from(value[i],'utf8'));continue;}
24
+ const c=value[++i];if(Object.hasOwn(escapes,c)){bytes.push(escapes[c]);continue;}
25
+ const octal=value.slice(i,i+3);if(!/^[0-3][0-7]{2}$/.test(octal))throw Error('Invalid blame path escape');bytes.push(parseInt(octal,8));i+=2;
26
+ }
27
+ return new TextDecoder('utf-8',{fatal:true}).decode(Uint8Array.from(bytes));
28
+ }
18
29
  export function parseBlame(text,maxLines=500){
19
30
  if(typeof text!=='string'||Buffer.byteLength(text,'utf8')>2*1024*1024)throw new Error('Invalid blame output size');
20
31
  if(!Number.isInteger(maxLines)||maxLines<1||maxLines>500)throw new Error('Invalid blame line limit');
@@ -35,7 +46,7 @@ export function parseBlame(text,maxLines=500){
35
46
  }
36
47
  if(i===fields.length||!['author','author-time','author-tz','summary','filename'].every(k=>Object.hasOwn(metadata,k)))throw new Error('Incomplete blame record');
37
48
  if(!/^-?[0-9]+$/.test(metadata['author-time'])||!Number.isSafeInteger(Number(metadata['author-time']))||! /^[+-][0-9]{4}$/.test(metadata['author-tz']))throw new Error('Invalid blame timestamp');
38
- rows.push({commit:header[1],originalLine,line,author:metadata.author,authorTime:Number(metadata['author-time']),authorTimezone:metadata['author-tz'],summary:metadata.summary,text:fields[i++].slice(1)});
49
+ rows.push({commit:header[1],originalLine,line,author:metadata.author,authorTime:Number(metadata['author-time']),authorTimezone:metadata['author-tz'],summary:metadata.summary,path:decodeBlamePath(metadata.filename),text:fields[i++].slice(1)});
39
50
  if(rows.length>maxLines)throw new Error('Blame line limit exceeded');
40
51
  }
41
52
  return rows;
package/index.mjs CHANGED
@@ -25,7 +25,7 @@ const rediscover = () => Object.assign(new Error('Repository authorization is mi
25
25
  import {historySearchArgs} from './git-history.mjs';
26
26
  function validatePayload(endpoint, payload) {
27
27
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
28
- const fields = endpoint==='vcs/tree-file'?['sessionId','repositoryId','commit','path'] : endpoint==='vcs/references'?['sessionId','repositoryId'] : endpoint==='vcs/commit-image'?['sessionId','repositoryId','commit','parentIndex','id','side'] : endpoint==='vcs/tree'?['sessionId','repositoryId','commit'] : endpoint==='vcs/file-history'?['sessionId','repositoryId','commit','parentIndex','id','offset','limit','follow'] : ['vcs/revision-changes','vcs/revision-compare'].includes(endpoint) ? ['sessionId','repositoryId','base','target',...(endpoint==='vcs/revision-compare'?['id']:[])] : ['vcs/commit-compare','vcs/blame'].includes(endpoint) ? ['sessionId','repositoryId','commit','parentIndex','id'] : endpoint === 'vcs/commit' ? ['sessionId','repositoryId','commit','parentIndex'] : endpoint === 'vcs/history' ? ['sessionId','repositoryId','snapshot','offset','limit','search'] : endpoint === 'vcs/repositories' ? ['sessionId', 'subdirectory'] : ['sessionId', 'repositoryId', 'mode', ...(endpoint === 'vcs/compare' ? ['id'] : [])];
28
+ const fields = endpoint==='vcs/tree-file'?['sessionId','repositoryId','commit','path'] : endpoint==='vcs/references'?['sessionId','repositoryId'] : endpoint==='vcs/commit-image'?['sessionId','repositoryId','commit','parentIndex','id','side'] : endpoint==='vcs/tree'?['sessionId','repositoryId','commit'] : endpoint==='vcs/file-history'?['sessionId','repositoryId','commit','parentIndex','id','offset','limit','follow'] : ['vcs/revision-changes','vcs/revision-compare'].includes(endpoint) ? ['sessionId','repositoryId','base','target',...(endpoint==='vcs/revision-compare'?['id']:[])] : ['vcs/commit-compare','vcs/blame'].includes(endpoint) ? ['sessionId','repositoryId','commit','parentIndex','id',...(endpoint==='vcs/blame'?['startLine','lineLimit']:[])] : endpoint === 'vcs/commit' ? ['sessionId','repositoryId','commit','parentIndex'] : endpoint === 'vcs/history' ? ['sessionId','repositoryId','snapshot','offset','limit','search'] : endpoint === 'vcs/repositories' ? ['sessionId', 'subdirectory'] : ['sessionId', 'repositoryId', 'mode', ...(endpoint === 'vcs/compare' ? ['id'] : [])];
29
29
  if (Object.keys(payload).some(key => !fields.includes(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
30
30
  if (endpoint === 'vcs/repositories') {
31
31
  if (payload.subdirectory !== undefined) {
@@ -45,6 +45,7 @@ function validatePayload(endpoint, payload) {
45
45
  if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
46
46
  if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
47
47
  }
48
+ 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.');
48
49
  if(endpoint==='vcs/file-history'&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
49
50
  if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
50
51
  if(['vcs/history','vcs/file-history'].includes(endpoint)){
@@ -133,7 +134,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
133
134
  : endpoint === 'vcs/tree'
134
135
  ? await api.getHistoricalTree({...repository},{commit:payload.commit,signal})
135
136
  : endpoint === 'vcs/blame'
136
- ? await api.getFileBlame({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,signal})
137
+ ? await api.getFileBlame({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,...(payload.startLine!==undefined?{startLine:payload.startLine}:{}),...(payload.lineLimit!==undefined?{lineLimit:payload.lineLimit}:{}),signal})
137
138
  : endpoint === 'vcs/file-history'
138
139
  ? await api.listFileHistory({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,offset:payload.offset??0,limit:payload.limit??50,...(payload.follow!==undefined?{follow:payload.follow}:{}),signal})
139
140
  : endpoint === 'vcs/revision-changes'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.56",
3
+ "version": "0.3.57",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
package/vcs.mjs CHANGED
@@ -331,21 +331,24 @@ export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',
331
331
  const {data,...metadata}=await preparePng(raw,{signal});
332
332
  return {commit:revision,path:file,oid,...metadata,base64:data.toString('base64')};
333
333
  }
334
- export async function getFileBlame(repo,{commit,parentIndex=0,id,signal}={}){
334
+ export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lineLimit=500,signal}={}){
335
+ if(!Number.isInteger(startLine)||startLine<1||startLine>100001||!Number.isInteger(lineLimit)||lineLimit<1||lineLimit>500)throw Error('Invalid blame line window');
335
336
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
336
337
  const details=await commitDetails(repo,{commit,parentIndex,signal});
337
338
  const entry=details.changes.find(row=>row.id===id);if(!entry)throw new Error('Change is not part of selected commit');
338
- const result={commit,path:entry.path,lines:[],truncated:false};
339
+ const result={commit,path:entry.path,lines:[],truncated:false,startLine,nextLine:null};
339
340
  if(entry.status==='deleted')return {...result,notice:'File is deleted at this commit; blame unavailable.'};
340
341
  const blob=await historicalBlob(repo.root,commit,entry.path,signal);
341
342
  if(blob.notice||blob.binary||blob.encoding!=='UTF-8')return {...result,notice:blob.notice||'Blame supports UTF-8 regular text only.'};
342
343
  if(!blob.text)return result;
343
- const count=blob.text.split('\n').length-(blob.text.endsWith('\n')?1:0),limit=Math.min(count,500);
344
+ const count=blob.text.split('\n').length-(blob.text.endsWith('\n')?1:0);
345
+ if(startLine>count)return {...result,totalLines:count};
346
+ const endLine=Math.min(count,startLine+lineLimit-1),limit=endLine-startLine+1;
344
347
  let ignored;
345
348
  try{ignored=(await git(repo.root,['config','--null','--get-all','blame.ignoreRevsFile'],MAX_TEXT,{signal})).toString('utf8');}catch(e){if(e.code!=='VCS_COMMAND'||e.exitCode!==1)throw e;}
346
349
  if(ignored?.split(String.fromCharCode(0)).some(value=>value.length>0))return {...result,notice:'Blame unavailable while blame.ignoreRevsFile is configured; external revision files are not read.'};
347
- const output=await git(repo.root,['--literal-pathspecs','blame','--ignore-revs-file=','--line-porcelain','--no-textconv','--encoding=UTF-8','-L','1,'+limit,commit,'--',entry.path],MAX_TEXT,{signal});
348
- return {...result,lines:parseBlame(output.toString('utf8'),limit),truncated:count>limit};
350
+ const output=await git(repo.root,['--literal-pathspecs','blame','--ignore-revs-file=','--line-porcelain','--no-textconv','--encoding=UTF-8','-L',startLine+','+endLine,commit,'--',entry.path],MAX_TEXT,{signal});
351
+ return {...result,lines:parseBlame(output.toString('utf8'),limit),totalLines:count,nextLine:endLine<count&&endLine<100001?endLine+1:null,truncated:endLine<count};
349
352
  }
350
353
  export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,follow=false,signal}={}){
351
354
  if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100)throw new Error('Invalid history pagination');