@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/dist/client.js +3 -2
- package/dist/editor.js +1 -1
- package/git-history.mjs +33 -1
- package/index.mjs +5 -3
- package/package.json +1 -1
- package/vcs.mjs +17 -7
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
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)
|
|
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','
|
|
348
|
-
return {...result,lines:parseBlame(output.toString('utf8'),limit),truncated:count
|
|
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
|
}
|