@feng0531/dsh-vcs 0.3.55 → 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;
@@ -72,6 +83,27 @@ export function historyPage(rows,snapshot,offset,limit){
72
83
  const truncated=hasMore&&offset+limit>10000;
73
84
  return {snapshot,commits:rows.slice(0,limit),nextOffset:hasMore&&!truncated?offset+limit:null,truncated};
74
85
  }
86
+ // --follow emits one selected-path name-status record per commit. Fail closed on unexpected framing.
87
+ export function parseFollowHistory(text,path,maxRecords){
88
+ if(typeof text!=='string'||Buffer.byteLength(text,'utf8')>2*1024*1024||!Number.isInteger(maxRecords)||maxRecords<1||maxRecords>10101)throw Error('Invalid follow history bounds');
89
+ if(!text)return [];if(!text.endsWith('\0'))throw Error('Truncated follow history');
90
+ const fields=text.slice(0,-1).split('\0'),rows=[];let current=path;
91
+ for(let i=0;i<fields.length;){
92
+ if(rows.length===maxRecords||i+6>=fields.length)throw Error('Invalid follow history framing');
93
+ const row=parseHistory(fields.slice(i,i+5).join('\0')+'\0',1)[0];i+=5;
94
+ const status=fields[i++];
95
+ if(!/^\n(?:[AMDT]|[RC][0-9]{1,3})$/.test(status))throw Error('Unsupported follow history status');
96
+ const oldPath=fields[i++],newPath=/^\n[RC]/.test(status)?fields[i++]:oldPath;
97
+ if(!oldPath||!newPath||newPath!==current)throw Error('Ambiguous follow history path');
98
+ if(/^\n[RC]/.test(status)&&Number(status.slice(2))>100)throw Error('Invalid follow similarity score');
99
+ const boundary=row.parents.length>1?'merge-first-parent':(/^\nC/.test(status)||(/^\nR/.test(status)&&status!=='\nR100'))?'copy-or-inexact-rename':null;
100
+ rows.push({...row,path:current,...(boundary?{boundary}:status==='\nR100'?{oldPath}:{})});
101
+ if(boundary)break;
102
+ if(status==='\nR100')current=oldPath;
103
+ if(status==='\nA')break; // Stop at creation; never cross an older unrelated same-name file.
104
+ }
105
+ return rows;
106
+ }
75
107
  export function parseHistory(text, maxRecords = 101) {
76
108
  if(typeof text !== 'string' || Buffer.byteLength(text,'utf8') > 2*1024*1024) throw new Error('History output exceeds limit or is invalid');
77
109
  if(!Number.isInteger(maxRecords)||maxRecords<1||maxRecords>101) throw new Error('Invalid history record limit');
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'] : ['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,8 @@ 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.');
49
+ if(endpoint==='vcs/file-history'&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
48
50
  if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
49
51
  if(['vcs/history','vcs/file-history'].includes(endpoint)){
50
52
  if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
@@ -132,9 +134,9 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
132
134
  : endpoint === 'vcs/tree'
133
135
  ? await api.getHistoricalTree({...repository},{commit:payload.commit,signal})
134
136
  : endpoint === 'vcs/blame'
135
- ? 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})
136
138
  : endpoint === 'vcs/file-history'
137
- ? await api.listFileHistory({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,offset:payload.offset??0,limit:payload.limit??50,signal})
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})
138
140
  : endpoint === 'vcs/revision-changes'
139
141
  ? await api.getRevisionChanges({...repository},{base:payload.base,target:payload.target,signal})
140
142
  : endpoint === 'vcs/revision-compare'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.55",
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
@@ -1,6 +1,6 @@
1
1
  import {preparePng} from './image-preview.mjs';
2
2
  import { spawn } from 'node:child_process';
3
- import {parseHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT} from './git-history.mjs';
3
+ import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT} from './git-history.mjs';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { createHash } from 'node:crypto';
@@ -331,28 +331,38 @@ 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
- export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,signal}={}){
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');
352
355
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
356
+ if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
353
357
  const details=await commitDetails(repo,{commit,parentIndex,signal});
354
358
  const entry=details.changes.find(row=>row.id===id);
355
359
  if(!entry)throw new Error('Change is not part of selected commit');
360
+ if(follow){
361
+ const count=offset+limit+1;
362
+ 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});
363
+ const rows=parseFollowHistory(output.toString('utf8'),entry.path,count).slice(offset);
364
+ return {...historyPage(rows,commit,offset,limit),path:entry.path,followsRenames:true,followPolicy:'exact-first-parent'};
365
+ }
356
366
  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});
357
367
  return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
358
368
  }