@feng0531/dsh-vcs 0.3.56 → 0.3.59
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 +12 -1
- package/index.mjs +16 -10
- package/package.json +1 -1
- package/vcs.mjs +59 -5
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/workspace-image'?['sessionId','repositoryId','mode','id','side']:endpoint==='vcs/revision-image'?['sessionId','repositoryId','base','target','id','side'] : 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','vcs/revision-image'].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) {
|
|
@@ -35,16 +35,17 @@ function validatePayload(endpoint, payload) {
|
|
|
35
35
|
} else {
|
|
36
36
|
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
37
37
|
if(endpoint==='vcs/tree-file'&&(typeof payload.path!=='string'||!payload.path||payload.path.length>32768||payload.path.includes('\0')||payload.path.startsWith('/')||payload.path.split('/').some(part=>!part||part==='.'||part==='..')))throw invalid('Invalid historical file path.');
|
|
38
|
-
if(
|
|
39
|
-
if(['vcs/revision-changes','vcs/revision-compare'].includes(endpoint)){
|
|
38
|
+
if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
|
|
39
|
+
if(['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint)){
|
|
40
40
|
for(const oid of [payload.base,payload.target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw invalid('Invalid revision commit id.');
|
|
41
|
-
if(
|
|
41
|
+
if(['vcs/revision-compare','vcs/revision-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
|
|
42
42
|
}
|
|
43
43
|
if(['vcs/commit-compare','vcs/file-history','vcs/blame','vcs/commit-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid historical change id.');
|
|
44
44
|
if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/commit-image'].includes(endpoint)){
|
|
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)){
|
|
@@ -52,8 +53,9 @@ function validatePayload(endpoint, payload) {
|
|
|
52
53
|
if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
|
|
53
54
|
if(!Number.isInteger(payload.offset??0)||(payload.offset??0)<0||(payload.offset??0)>10000||!Number.isInteger(payload.limit??50)||(payload.limit??50)<1||(payload.limit??50)>100)throw invalid('Invalid history pagination.');
|
|
54
55
|
}
|
|
56
|
+
if(endpoint==='vcs/workspace-image'&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid workspace image change id.');
|
|
55
57
|
if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
56
|
-
if (
|
|
58
|
+
if (['vcs/compare','vcs/workspace-image'].includes(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -73,7 +75,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
73
75
|
if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
|
|
74
76
|
}
|
|
75
77
|
return async (endpoint, payload, signal) => {
|
|
76
|
-
if (!['vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/commit-image', 'vcs/references'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
78
|
+
if (!['vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image', 'vcs/references'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
77
79
|
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
78
80
|
active++;
|
|
79
81
|
let imageSlot=false;
|
|
@@ -119,12 +121,16 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
119
121
|
if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
|
|
120
122
|
// Both adapter operations revalidate the canonical root; comparison also validates
|
|
121
123
|
// the change ID against fresh status, so do not duplicate a full status scan here.
|
|
122
|
-
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/commit-image','vcs/references'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
|
|
123
|
-
if(
|
|
124
|
+
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/commit-image','vcs/revision-image','vcs/workspace-image','vcs/references'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
|
|
125
|
+
if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)){
|
|
124
126
|
if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
|
|
125
127
|
activeImages++;imageSlot=true;
|
|
126
128
|
}
|
|
127
|
-
const value = endpoint === 'vcs/
|
|
129
|
+
const value = endpoint === 'vcs/workspace-image'
|
|
130
|
+
? await api.getWorkspaceImage({...repository},{mode,id:payload.id,side:payload.side??'right',signal})
|
|
131
|
+
: endpoint === 'vcs/revision-image'
|
|
132
|
+
? await api.getRevisionImage({...repository},{base:payload.base,target:payload.target,id:payload.id,side:payload.side??'right',signal})
|
|
133
|
+
: endpoint === 'vcs/references'
|
|
128
134
|
? await api.listReferences({...repository},{signal})
|
|
129
135
|
: endpoint === 'vcs/commit-image'
|
|
130
136
|
? await api.getCommitImage({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,side:payload.side??'right',signal})
|
|
@@ -133,7 +139,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
133
139
|
: endpoint === 'vcs/tree'
|
|
134
140
|
? await api.getHistoricalTree({...repository},{commit:payload.commit,signal})
|
|
135
141
|
: endpoint === 'vcs/blame'
|
|
136
|
-
? await api.getFileBlame({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,signal})
|
|
142
|
+
? 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
143
|
: endpoint === 'vcs/file-history'
|
|
138
144
|
? 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
145
|
: endpoint === 'vcs/revision-changes'
|
package/package.json
CHANGED
package/vcs.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import {preparePng} from './image-preview.mjs';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
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
|
+
import {constants as fileFlags} from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
@@ -322,6 +323,9 @@ export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',
|
|
|
322
323
|
if(!entry)throw new Error('Change is not part of selected commit');
|
|
323
324
|
const revision=side==='left'?details.parent:commit,file=side==='left'?(entry.oldPath||entry.path):entry.path;
|
|
324
325
|
if(!revision||(side==='left'&&entry.status==='added')||(side==='right'&&entry.status==='deleted'))return {commit:revision,path:file,absent:true};
|
|
326
|
+
return historicalImage(repo,revision,file,signal);
|
|
327
|
+
}
|
|
328
|
+
async function historicalImage(repo,revision,file,signal){
|
|
325
329
|
const listing=(await git(repo.root,['--literal-pathspecs','ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
|
|
326
330
|
const record=listing.split(String.fromCharCode(0)).find(row=>row.slice(row.indexOf('\t')+1)===file);
|
|
327
331
|
if(!record)throw new Error('Historical path missing');
|
|
@@ -331,21 +335,71 @@ export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',
|
|
|
331
335
|
const {data,...metadata}=await preparePng(raw,{signal});
|
|
332
336
|
return {commit:revision,path:file,oid,...metadata,base64:data.toString('base64')};
|
|
333
337
|
}
|
|
334
|
-
|
|
338
|
+
async function workspacePng(buffer,signal){const {data,...metadata}=await preparePng(buffer,{signal});return {...metadata,base64:data.toString('base64')};}
|
|
339
|
+
// Workspace image reads use immutable HEAD/index blobs and a checked regular-file handle.
|
|
340
|
+
export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}={}){
|
|
341
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id)||!['left','right'].includes(side))throw Error('Invalid workspace image selection');
|
|
342
|
+
repo=await checkedRepo(repo,signal);if(repo.type!=='git')throw Error('Workspace PNG supports Git only');modeFor(repo,mode);
|
|
343
|
+
const entry=(await changes(repo,mode)).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status.');
|
|
344
|
+
if(entry.status==='conflicted')throw Error('Conflicted images are not supported');
|
|
345
|
+
const head=async()=>{try{return (await git(repo.root,['rev-parse','--verify','HEAD'],MAX_TEXT,{signal})).toString('utf8').trim();}catch(e){if(e.code==='VCS_COMMAND')return null;throw e;}};
|
|
346
|
+
const revision=await head();const index=await git(repo.root,['--literal-pathspecs','ls-files','--stage','-z','--',entry.path],MAX_TEXT,{signal});
|
|
347
|
+
const file=side==='left'&&mode!=='unstaged'?(entry.oldPath||entry.path):entry.path;
|
|
348
|
+
let result,verifyWorking;
|
|
349
|
+
const absent=()=>({path:file,commit:null,absent:true});
|
|
350
|
+
const readIndex=async()=>{
|
|
351
|
+
const rows=index.toString('utf8').split('\0').filter(Boolean);if(!rows.length)return absent();
|
|
352
|
+
if(rows.length!==1)throw Error('Unmerged index image');const match=/^(100644|100755) ([a-f0-9]{40}|[a-f0-9]{64}) 0\t/.exec(rows[0]);if(!match)throw Error('Image preview requires a regular file');
|
|
353
|
+
const buffer=await git(repo.root,['cat-file','blob',match[2]],MAX_TEXT,{signal});return {path:file,commit:'Index',...await workspacePng(buffer,signal)};
|
|
354
|
+
};
|
|
355
|
+
if(side==='left'){
|
|
356
|
+
if(entry.indexStatus==='?'||(mode==='unstaged'&&entry.indexStatus==='D')||(mode!=='unstaged'&&(!revision||['A','C'].includes(entry.indexStatus))))result=absent();
|
|
357
|
+
else result=mode==='unstaged'?await readIndex():await historicalImage(repo,revision,file,signal);
|
|
358
|
+
}else if(mode==='staged')result=entry.indexStatus==='D'?absent():await readIndex();
|
|
359
|
+
else{
|
|
360
|
+
const target=await confined(repo.root,file);let component=repo.root;for(const part of path.relative(repo.root,target).split(path.sep)){component=path.join(component,part);try{if((await fs.lstat(component)).isSymbolicLink())throw Error('Workspace image links are not supported');}catch(e){if(e.code!=='ENOENT')throw e;}}let before;try{before=await fs.lstat(target);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
361
|
+
if(!before)result=absent();else{
|
|
362
|
+
if(!before.isFile()||before.isSymbolicLink())throw Error('Image preview requires a regular file');if(before.size>MAX_TEXT)throw Error('Image exceeds 2 MiB limit');
|
|
363
|
+
const handle=await fs.open(target,fileFlags.O_RDONLY|(fileFlags.O_NOFOLLOW||0));try{
|
|
364
|
+
const fingerprint=s=>JSON.stringify([s.dev,s.ino,s.size,s.mtimeMs,s.ctimeMs]);const opened=await handle.stat();if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working image changed during read');
|
|
365
|
+
const buffer=Buffer.alloc(MAX_TEXT+1);const {bytesRead}=await handle.read(buffer,0,buffer.length,0);signal?.throwIfAborted();const after=await handle.stat(),named=await fs.lstat(target);await confined(repo.root,file);
|
|
366
|
+
if(named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before)||bytesRead!==before.size)throw Error('Working image changed during read');
|
|
367
|
+
result={path:file,commit:'Working tree',...await workspacePng(buffer.subarray(0,bytesRead),signal)};
|
|
368
|
+
verifyWorking=async()=>{await confined(repo.root,file);const validated=await fs.lstat(target);if(validated.isSymbolicLink()||fingerprint(validated)!==fingerprint(before))throw Error('Working image changed during validation');};await verifyWorking();
|
|
369
|
+
}finally{await handle.close();}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
signal?.throwIfAborted();const afterIndex=await git(repo.root,['--literal-pathspecs','ls-files','--stage','-z','--',entry.path],MAX_TEXT,{signal});
|
|
373
|
+
if(!index.equals(afterIndex)||revision!==await head()||!(await changes(repo,mode)).some(row=>row.id===id))throw Error('Workspace image selection changed; refresh repository status.');
|
|
374
|
+
await verifyWorking?.();signal?.throwIfAborted();return result;
|
|
375
|
+
}
|
|
376
|
+
export async function getRevisionImage(repo,{base,target,id,side='right',signal}={}){
|
|
377
|
+
if(!['left','right'].includes(side))throw Error('Invalid image side');
|
|
378
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw Error('Invalid revision change id');
|
|
379
|
+
const details=await getRevisionChanges(repo,{base,target,signal}),entry=details.changes.find(row=>row.id===id);
|
|
380
|
+
if(!entry)throw Error('Change is not part of selected revision pair');
|
|
381
|
+
const revision=side==='left'?base:target,file=side==='left'?(entry.oldPath||entry.path):entry.path;
|
|
382
|
+
if((side==='left'&&entry.status==='added')||(side==='right'&&entry.status==='deleted'))return {commit:revision,path:file,absent:true};
|
|
383
|
+
repo=await checkedRepo(repo,signal);return historicalImage(repo,revision,file,signal);
|
|
384
|
+
}
|
|
385
|
+
export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lineLimit=500,signal}={}){
|
|
386
|
+
if(!Number.isInteger(startLine)||startLine<1||startLine>100001||!Number.isInteger(lineLimit)||lineLimit<1||lineLimit>500)throw Error('Invalid blame line window');
|
|
335
387
|
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
336
388
|
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
337
389
|
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};
|
|
390
|
+
const result={commit,path:entry.path,lines:[],truncated:false,startLine,nextLine:null};
|
|
339
391
|
if(entry.status==='deleted')return {...result,notice:'File is deleted at this commit; blame unavailable.'};
|
|
340
392
|
const blob=await historicalBlob(repo.root,commit,entry.path,signal);
|
|
341
393
|
if(blob.notice||blob.binary||blob.encoding!=='UTF-8')return {...result,notice:blob.notice||'Blame supports UTF-8 regular text only.'};
|
|
342
394
|
if(!blob.text)return result;
|
|
343
|
-
const count=blob.text.split('\n').length-(blob.text.endsWith('\n')?1:0)
|
|
395
|
+
const count=blob.text.split('\n').length-(blob.text.endsWith('\n')?1:0);
|
|
396
|
+
if(startLine>count)return {...result,totalLines:count};
|
|
397
|
+
const endLine=Math.min(count,startLine+lineLimit-1),limit=endLine-startLine+1;
|
|
344
398
|
let ignored;
|
|
345
399
|
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
400
|
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
|
|
401
|
+
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});
|
|
402
|
+
return {...result,lines:parseBlame(output.toString('utf8'),limit),totalLines:count,nextLine:endLine<count&&endLine<100001?endLine+1:null,truncated:endLine<count};
|
|
349
403
|
}
|
|
350
404
|
export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,follow=false,signal}={}){
|
|
351
405
|
if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100)throw new Error('Invalid history pagination');
|