@feng0531/dsh-vcs 0.3.23 → 0.3.35
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/README.md +3 -1
- package/dist/client.js +3 -2
- package/dist/editor.js +88 -88
- package/git-history.mjs +75 -0
- package/image-preview.mjs +86 -0
- package/index.mjs +46 -5
- package/package.json +3 -1
- package/vcs.mjs +174 -3
package/git-history.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Each line-porcelain record carries its own metadata; never interpret filename as an access path.
|
|
2
|
+
export function parseBlame(text,maxLines=500){
|
|
3
|
+
if(typeof text!=='string'||Buffer.byteLength(text,'utf8')>2*1024*1024)throw new Error('Invalid blame output size');
|
|
4
|
+
if(!Number.isInteger(maxLines)||maxLines<1||maxLines>500)throw new Error('Invalid blame line limit');
|
|
5
|
+
if(!text)return [];
|
|
6
|
+
if(!text.endsWith('\n'))throw new Error('Truncated blame output');
|
|
7
|
+
const fields=text.slice(0,-1).split('\n'),rows=[];
|
|
8
|
+
for(let i=0;i<fields.length;){
|
|
9
|
+
const header=/^([a-f0-9]{40}|[a-f0-9]{64}) ([1-9][0-9]*) ([1-9][0-9]*)(?: ([1-9][0-9]*))?$/.exec(fields[i++]);
|
|
10
|
+
if(!header)throw new Error('Invalid blame header');
|
|
11
|
+
const originalLine=Number(header[2]),line=Number(header[3]);
|
|
12
|
+
if(!Number.isSafeInteger(originalLine)||!Number.isSafeInteger(line)||(rows.length&&line!==rows.at(-1).line+1))throw new Error('Invalid blame line sequence');
|
|
13
|
+
const metadata={};
|
|
14
|
+
while(i<fields.length&&!fields[i].startsWith(' ')){
|
|
15
|
+
const item=fields[i++],space=item.indexOf(' '),key=space<0?item:item.slice(0,space),value=space<0?'':item.slice(space+1);
|
|
16
|
+
if(['author','author-time','author-tz','summary','filename'].includes(key)){
|
|
17
|
+
if(Object.hasOwn(metadata,key))throw new Error('Duplicate blame metadata');metadata[key]=value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if(i===fields.length||!['author','author-time','author-tz','summary','filename'].every(k=>Object.hasOwn(metadata,k)))throw new Error('Incomplete blame record');
|
|
21
|
+
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');
|
|
22
|
+
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)});
|
|
23
|
+
if(rows.length>maxLines)throw new Error('Blame line limit exceeded');
|
|
24
|
+
}
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
// for-each-ref adds LF after each NUL-delimited record; ref names cannot contain LF.
|
|
28
|
+
export const REFS_FORMAT='%(refname)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)%00';
|
|
29
|
+
export function parseReferences(text){
|
|
30
|
+
if(typeof text!=='string'||Buffer.byteLength(text,'utf8')>2*1024*1024)throw Error('Invalid reference output size');
|
|
31
|
+
if(!text)return [];
|
|
32
|
+
if(!text.endsWith('\n'))throw Error('Truncated reference output');
|
|
33
|
+
const lines=text.slice(0,-1).split('\n');if(lines.length>1000)throw Error('Reference count exceeds limit');
|
|
34
|
+
const seen=new Set(),result=[];
|
|
35
|
+
for(const line of lines){
|
|
36
|
+
const fields=line.split('\0');if(fields.length!==6||fields[5]!=='')throw Error('Invalid reference framing');
|
|
37
|
+
const [name,type,id,peeledType,peeled]=fields;
|
|
38
|
+
const prefix=name.startsWith('refs/heads/')?'refs/heads/':name.startsWith('refs/tags/')?'refs/tags/':null;
|
|
39
|
+
if(!prefix||seen.has(name)||name.length>1024)throw Error('Invalid reference name');
|
|
40
|
+
const short=name.slice(prefix.length);
|
|
41
|
+
if(!short||short.endsWith('.')||short.includes('..')||short.includes('@{')||[...short].some(c=>c.charCodeAt(0)<=32||c.charCodeAt(0)===127||'~^:?*['.includes(c)||c===String.fromCharCode(92))||short.split('/').some(p=>!p||p.startsWith('.')||p.endsWith('.lock')))throw Error('Invalid reference name');
|
|
42
|
+
seen.add(name);
|
|
43
|
+
if(!oid(id)||!['commit','tag','tree','blob'].includes(type)||Boolean(peeledType)!==Boolean(peeled)||(peeled&&(!oid(peeled)||peeled.length!==id.length||!['commit','tag','tree','blob'].includes(peeledType))))throw Error('Invalid reference object');
|
|
44
|
+
if(type!=='tag'&&peeled)throw Error('Unexpected peeled object');
|
|
45
|
+
const commit=type==='commit'?id:type==='tag'&&peeledType==='commit'?peeled:null;
|
|
46
|
+
if(commit)result.push({name,shortName:short,kind:prefix==='refs/heads/'?'branch':'tag',commit});
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Format for: git log -z --format=<HISTORY_FORMAT>. NUL separates fields/records.
|
|
52
|
+
export const HISTORY_FORMAT = '%H%x00%P%x00%an%x00%aI%x00%s';
|
|
53
|
+
const oid = value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
|
|
54
|
+
export function historyPage(rows,snapshot,offset,limit){
|
|
55
|
+
const hasMore=rows.length>limit;
|
|
56
|
+
const truncated=hasMore&&offset+limit>10000;
|
|
57
|
+
return {snapshot,commits:rows.slice(0,limit),nextOffset:hasMore&&!truncated?offset+limit:null,truncated};
|
|
58
|
+
}
|
|
59
|
+
export function parseHistory(text, maxRecords = 101) {
|
|
60
|
+
if(typeof text !== 'string' || Buffer.byteLength(text,'utf8') > 2*1024*1024) throw new Error('History output exceeds limit or is invalid');
|
|
61
|
+
if(!Number.isInteger(maxRecords)||maxRecords<1||maxRecords>101) throw new Error('Invalid history record limit');
|
|
62
|
+
if(!text) return [];
|
|
63
|
+
if(!text.endsWith('\0')) throw new Error('Truncated history output');
|
|
64
|
+
const fields=text.slice(0,-1).split('\0');
|
|
65
|
+
if(fields.length%5 || fields.length/5>maxRecords) throw new Error('Invalid history field count or record limit');
|
|
66
|
+
const result=[];
|
|
67
|
+
for(let i=0;i<fields.length;i+=5){
|
|
68
|
+
const [id,parentText,author,date,subject]=fields.slice(i,i+5);
|
|
69
|
+
const parents=parentText?parentText.split(' '):[];
|
|
70
|
+
if(!oid(id)||parents.some(p=>!oid(p)||p.length!==id.length)) throw new Error('Invalid history object id');
|
|
71
|
+
if(!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:Z|[+-][0-9]{2}:[0-9]{2})$/.test(date)||!Number.isFinite(Date.parse(date))) throw new Error('Invalid history timestamp');
|
|
72
|
+
result.push({id,parents,author,date,subject});
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {inflate} from 'node:zlib';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
const inflateAsync=promisify(inflate);
|
|
4
|
+
// Structural gate only. Pixel decoding must still succeed before display.
|
|
5
|
+
export function inspectPng(buffer){
|
|
6
|
+
if(!Buffer.isBuffer(buffer)||buffer.length>2*1024*1024)throw new Error('Image byte limit exceeded or invalid input');
|
|
7
|
+
if(buffer.length<8||!buffer.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))throw new Error('Only PNG is supported');
|
|
8
|
+
let offset=8,width,height,depth,color,paletteSize=0,seenTransparency=false,seenPalette=false,seenData=false,endedData=false,ended=false,chunks=0;
|
|
9
|
+
while(offset<buffer.length){
|
|
10
|
+
if(++chunks>4096||buffer.length-offset<12)throw new Error('Invalid PNG chunk bounds');
|
|
11
|
+
const length=buffer.readUInt32BE(offset),end=offset+12+length;
|
|
12
|
+
if(end>buffer.length)throw new Error('Truncated PNG chunk');
|
|
13
|
+
const type=buffer.toString('ascii',offset+4,offset+8);
|
|
14
|
+
if(!buffer.subarray(offset+4,offset+8).every(b=>(b>=65&&b<=90)||(b>=97&&b<=122)))throw new Error('Invalid PNG chunk type');
|
|
15
|
+
if(buffer[offset+6]&32)throw new Error('Invalid PNG reserved chunk bit');
|
|
16
|
+
let crc=0xffffffff;
|
|
17
|
+
for(const byte of buffer.subarray(offset+4,end-4)){crc^=byte;for(let bit=0;bit<8;bit++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);}
|
|
18
|
+
if(((crc^0xffffffff)>>>0)!==buffer.readUInt32BE(end-4))throw new Error('Invalid PNG CRC');
|
|
19
|
+
if(chunks===1&&type!=='IHDR')throw new Error('PNG header must be first');
|
|
20
|
+
if(type==='IHDR'){
|
|
21
|
+
if(chunks!==1||length!==13)throw new Error('Invalid PNG header');
|
|
22
|
+
width=buffer.readUInt32BE(offset+8);height=buffer.readUInt32BE(offset+12);
|
|
23
|
+
if(!width||!height||width>8192||height>8192||width*height>16000000)throw new Error('Image dimensions exceed limit');
|
|
24
|
+
depth=buffer[offset+16];color=buffer[offset+17];
|
|
25
|
+
if(!({0:[1,2,4,8,16],2:[8,16],3:[1,2,4,8],4:[8,16],6:[8,16]}[color]?.includes(depth))||buffer[offset+18]!==0||buffer[offset+19]!==0||buffer[offset+20]>1)throw new Error('Invalid PNG encoding');
|
|
26
|
+
}else if(type==='PLTE'){
|
|
27
|
+
if(seenPalette||seenTransparency||seenData||color===0||color===4||!length||length%3||length>768||(color===3&&length/3>2**depth))throw new Error('Invalid PNG palette');
|
|
28
|
+
seenPalette=true;paletteSize=length/3;
|
|
29
|
+
}else if(type==='tRNS'){
|
|
30
|
+
if(seenTransparency||seenData||![0,2,3].includes(color)||(color===0&&length!==2)||(color===2&&length!==6)||(color===3&&(!seenPalette||!length||length>paletteSize)))throw new Error('Invalid PNG transparency');
|
|
31
|
+
if(color!==3)for(let i=0;i<length;i+=2)if(buffer.readUInt16BE(offset+8+i)>=2**depth)throw new Error('Invalid PNG transparent sample');
|
|
32
|
+
seenTransparency=true;
|
|
33
|
+
}else if(type==='IDAT'){
|
|
34
|
+
if(color===3&&!seenPalette)throw new Error('Missing PNG palette');
|
|
35
|
+
if(endedData)throw new Error('Noncontiguous PNG data');seenData=true;
|
|
36
|
+
}else{
|
|
37
|
+
if(seenData)endedData=true;
|
|
38
|
+
if(type==='IEND'){if(length||!seenData||end!==buffer.length)throw new Error('Invalid PNG end');ended=true;}
|
|
39
|
+
else if(['iCCP','zTXt','iTXt'].includes(type))throw new Error('Compressed or international PNG metadata is unsupported');
|
|
40
|
+
else if(['acTL','fcTL','fdAT'].includes(type))throw new Error('Animated PNG is unsupported');
|
|
41
|
+
else if(type[0]===type[0].toUpperCase()&&type!=='PLTE')throw new Error('Unknown critical PNG chunk');
|
|
42
|
+
}
|
|
43
|
+
offset=end;
|
|
44
|
+
}
|
|
45
|
+
if(!ended)throw new Error('Missing PNG end');
|
|
46
|
+
return {mime:'image/png',width,height,bytes:buffer.length};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Stripping color metadata makes this unsuitable for color-critical review.
|
|
50
|
+
export async function preparePng(buffer,options){
|
|
51
|
+
const metadata=await validatePng(buffer,options),parts=[buffer.subarray(0,8)];
|
|
52
|
+
for(let offset=8;offset<buffer.length;){
|
|
53
|
+
const length=buffer.readUInt32BE(offset),end=offset+length+12,type=buffer.toString("ascii",offset+4,offset+8);
|
|
54
|
+
if(["IHDR","PLTE","tRNS","IDAT","IEND"].includes(type))parts.push(buffer.subarray(offset,end));
|
|
55
|
+
offset=end;
|
|
56
|
+
}
|
|
57
|
+
options?.signal?.throwIfAborted();
|
|
58
|
+
const data=Buffer.concat(parts);
|
|
59
|
+
return {...metadata,bytes:data.length,originalBytes:metadata.bytes,data,metadataStripped:data.length!==buffer.length};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fail fast instead of retaining an unbounded queue of image buffers.
|
|
63
|
+
let activeValidations=0;
|
|
64
|
+
export async function validatePng(buffer,{signal}={}){
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
if(activeValidations>=2)throw Object.assign(new Error('Image validation busy; retry shortly'),{code:'IMAGE_BUSY'});
|
|
67
|
+
activeValidations++;
|
|
68
|
+
try{return await validatePngStream(buffer,{signal});}finally{activeValidations--;}
|
|
69
|
+
}
|
|
70
|
+
async function validatePngStream(buffer,{signal}){
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
const metadata=inspectPng(buffer);
|
|
73
|
+
const depth=buffer[24],color=buffer[25];
|
|
74
|
+
if(buffer[28]!==0)throw new Error('Interlaced PNG is unsupported');
|
|
75
|
+
const channels={0:1,2:3,3:1,4:2,6:4}[color],stride=Math.ceil(metadata.width*channels*depth/8)+1;
|
|
76
|
+
const expected=stride*metadata.height;
|
|
77
|
+
if(expected>64*1024*1024)throw new Error('PNG decoded byte limit exceeded');
|
|
78
|
+
const parts=[];
|
|
79
|
+
for(let offset=8;offset<buffer.length;){const length=buffer.readUInt32BE(offset);if(buffer.toString('ascii',offset+4,offset+8)==='IDAT')parts.push(buffer.subarray(offset+8,offset+8+length));offset+=length+12;}
|
|
80
|
+
const compressed=Buffer.concat(parts);
|
|
81
|
+
const result=await inflateAsync(compressed,{maxOutputLength:expected,info:true,signal});
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
if(result.engine.bytesWritten!==compressed.length||result.buffer.length!==expected)throw new Error('Invalid PNG decoded length');
|
|
84
|
+
for(let row=0;row<metadata.height;row++)if(result.buffer[row*stride]>4)throw new Error('Invalid PNG row filter');
|
|
85
|
+
return metadata;
|
|
86
|
+
}
|
package/index.mjs
CHANGED
|
@@ -24,7 +24,7 @@ const rediscover = () => Object.assign(new Error('Repository authorization is mi
|
|
|
24
24
|
|
|
25
25
|
function validatePayload(endpoint, payload) {
|
|
26
26
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
|
|
27
|
-
const fields = endpoint === 'vcs/repositories' ? ['sessionId', 'subdirectory'] : ['sessionId', 'repositoryId', 'mode', ...(endpoint === 'vcs/compare' ? ['id'] : [])];
|
|
27
|
+
const fields = 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'] : endpoint === 'vcs/repositories' ? ['sessionId', 'subdirectory'] : ['sessionId', 'repositoryId', 'mode', ...(endpoint === 'vcs/compare' ? ['id'] : [])];
|
|
28
28
|
if (Object.keys(payload).some(key => !fields.includes(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
|
|
29
29
|
if (endpoint === 'vcs/repositories') {
|
|
30
30
|
if (payload.subdirectory !== undefined) {
|
|
@@ -33,6 +33,21 @@ function validatePayload(endpoint, payload) {
|
|
|
33
33
|
}
|
|
34
34
|
} else {
|
|
35
35
|
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
36
|
+
if(endpoint==='vcs/commit-image'&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
|
|
37
|
+
if(['vcs/revision-changes','vcs/revision-compare'].includes(endpoint)){
|
|
38
|
+
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.');
|
|
39
|
+
if(endpoint==='vcs/revision-compare'&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
|
|
40
|
+
}
|
|
41
|
+
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.');
|
|
42
|
+
if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/commit-image'].includes(endpoint)){
|
|
43
|
+
if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
|
|
44
|
+
if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
|
|
45
|
+
}
|
|
46
|
+
if(['vcs/history','vcs/file-history'].includes(endpoint)){
|
|
47
|
+
if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
|
|
48
|
+
if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
|
|
49
|
+
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.');
|
|
50
|
+
}
|
|
36
51
|
if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
37
52
|
if (endpoint === 'vcs/compare' && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
38
53
|
}
|
|
@@ -40,7 +55,7 @@ function validatePayload(endpoint, payload) {
|
|
|
40
55
|
|
|
41
56
|
/** Limit active adapter operations and retain only bounded, short-lived discovery grants. */
|
|
42
57
|
export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512 } = {}) {
|
|
43
|
-
let active = 0;
|
|
58
|
+
let active = 0, activeImages = 0;
|
|
44
59
|
const sessions = new Map();
|
|
45
60
|
function current(sessionId, cwd) {
|
|
46
61
|
for (const [id, entry] of sessions) if (entry.expiresAt <= now()) sessions.delete(id);
|
|
@@ -54,9 +69,10 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
54
69
|
if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
|
|
55
70
|
}
|
|
56
71
|
return async (endpoint, payload, signal) => {
|
|
57
|
-
if (!['vcs/repositories', 'vcs/status', 'vcs/compare'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
72
|
+
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/commit-image', 'vcs/references'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
58
73
|
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
59
74
|
active++;
|
|
75
|
+
let imageSlot=false;
|
|
60
76
|
try {
|
|
61
77
|
signal?.throwIfAborted();
|
|
62
78
|
validatePayload(endpoint, payload);
|
|
@@ -99,7 +115,32 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
99
115
|
if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
|
|
100
116
|
// Both adapter operations revalidate the canonical root; comparison also validates
|
|
101
117
|
// the change ID against fresh status, so do not duplicate a full status scan here.
|
|
102
|
-
|
|
118
|
+
if(['vcs/history','vcs/commit','vcs/commit-compare','vcs/revision-changes','vcs/revision-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/commit-image','vcs/references'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
|
|
119
|
+
if(endpoint==='vcs/commit-image'){
|
|
120
|
+
if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
|
|
121
|
+
activeImages++;imageSlot=true;
|
|
122
|
+
}
|
|
123
|
+
const value = endpoint === 'vcs/references'
|
|
124
|
+
? await api.listReferences({...repository},{signal})
|
|
125
|
+
: endpoint === 'vcs/commit-image'
|
|
126
|
+
? await api.getCommitImage({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,side:payload.side??'right',signal})
|
|
127
|
+
: endpoint === 'vcs/tree'
|
|
128
|
+
? await api.getHistoricalTree({...repository},{commit:payload.commit,signal})
|
|
129
|
+
: endpoint === 'vcs/blame'
|
|
130
|
+
? await api.getFileBlame({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,signal})
|
|
131
|
+
: endpoint === 'vcs/file-history'
|
|
132
|
+
? await api.listFileHistory({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,offset:payload.offset??0,limit:payload.limit??50,signal})
|
|
133
|
+
: endpoint === 'vcs/revision-changes'
|
|
134
|
+
? await api.getRevisionChanges({...repository},{base:payload.base,target:payload.target,signal})
|
|
135
|
+
: endpoint === 'vcs/revision-compare'
|
|
136
|
+
? await api.getRevisionComparison({...repository},{base:payload.base,target:payload.target,id:payload.id,signal})
|
|
137
|
+
: endpoint === 'vcs/commit-compare'
|
|
138
|
+
? await api.getCommitComparison({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,signal})
|
|
139
|
+
: endpoint === 'vcs/commit'
|
|
140
|
+
? await api.getCommitDetails({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,signal})
|
|
141
|
+
: endpoint === 'vcs/history'
|
|
142
|
+
? await api.listHistory({...repository},{snapshot:payload.snapshot,offset:payload.offset??0,limit:payload.limit??50,signal})
|
|
143
|
+
: endpoint === 'vcs/status'
|
|
103
144
|
? { cwd, repository: { ...repository }, changes: await api.listChanges({ ...repository }, mode), mode }
|
|
104
145
|
: await api.getComparison({ ...repository }, { mode, id: payload.id });
|
|
105
146
|
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
@@ -108,7 +149,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
|
|
|
108
149
|
} catch (error) {
|
|
109
150
|
if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
|
|
110
151
|
return failure(typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed', error instanceof Error ? error.message : String(error));
|
|
111
|
-
} finally { active--; }
|
|
152
|
+
} finally { active--; if(imageSlot)activeImages--; }
|
|
112
153
|
};
|
|
113
154
|
}
|
|
114
155
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feng0531/dsh-vcs",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"index.mjs",
|
|
16
16
|
"vcs.mjs",
|
|
17
|
+
"git-history.mjs",
|
|
18
|
+
"image-preview.mjs",
|
|
17
19
|
"dist",
|
|
18
20
|
"locale",
|
|
19
21
|
"icon.svg",
|
package/vcs.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import {preparePng} from './image-preview.mjs';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import {parseHistory,HISTORY_FORMAT,historyPage,parseBlame,parseReferences,REFS_FORMAT} from './git-history.mjs';
|
|
2
4
|
import * as fs from 'node:fs/promises';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { createHash } from 'node:crypto';
|
|
@@ -14,7 +16,7 @@ function run(command, args, cwd, max = MAX_OUTPUT, { signal, timeoutMs = TIMEOUT
|
|
|
14
16
|
if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' })); return; }
|
|
15
17
|
// Inherited Git overrides must not redirect reads to a different repository.
|
|
16
18
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
|
|
17
|
-
Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
|
|
19
|
+
Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
|
|
18
20
|
const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
19
21
|
const out = [], err = []; let size = 0, error, done = false;
|
|
20
22
|
const stop = e => { error ??= e; child.kill(); };
|
|
@@ -211,12 +213,151 @@ export async function discoverRepositories(cwd, { signal, maxDepth = 6, maxDirec
|
|
|
211
213
|
return result;
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
async function checkedRepo(repo) {
|
|
216
|
+
async function checkedRepo(repo, signal) {
|
|
217
|
+
signal?.throwIfAborted();
|
|
215
218
|
if (!repo || !['git', 'svn'].includes(repo.type) || typeof repo.root !== 'string') throw new Error('Invalid repository');
|
|
216
|
-
const found = await repositoryAt(repo.root, repo.type);
|
|
219
|
+
const found = await repositoryAt(repo.root, repo.type, () => ({signal}));
|
|
220
|
+
signal?.throwIfAborted();
|
|
217
221
|
if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
|
|
218
222
|
return found;
|
|
219
223
|
}
|
|
224
|
+
export async function listHistory(repo, {snapshot,offset=0,limit=50,signal} = {}) {
|
|
225
|
+
if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100) throw new Error('Invalid history pagination');
|
|
226
|
+
if(snapshot!==undefined && (typeof snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(snapshot))) throw new Error('Invalid history snapshot');
|
|
227
|
+
if(signal?.aborted) throw Object.assign(new Error('History cancelled'),{code:'ABORT_ERR'});
|
|
228
|
+
repo=await checkedRepo(repo,signal);
|
|
229
|
+
if(repo.type!=='git') throw new Error('History currently supports Git only');
|
|
230
|
+
if(!snapshot){
|
|
231
|
+
try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
|
|
232
|
+
catch(e){
|
|
233
|
+
if(e.code!=='VCS_COMMAND')throw e;
|
|
234
|
+
// Distinguish an unborn symbolic HEAD from corruption or other command failures.
|
|
235
|
+
const ref=(await git(repo.root,['symbolic-ref','-q','HEAD'],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
236
|
+
try{await git(repo.root,['show-ref','--verify','--quiet',ref],MAX_TEXT,{signal});}
|
|
237
|
+
catch(missing){if(missing.code==='VCS_COMMAND'&&missing.exitCode===1)return {snapshot:null,commits:[],nextOffset:null};throw missing;}
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const text=await git(repo.root,['log','-z','--no-show-signature','--encoding=UTF-8','--topo-order','--max-count='+String(limit+1),'--skip='+String(offset),'--format='+HISTORY_FORMAT,snapshot,'--'],MAX_TEXT,{signal});
|
|
242
|
+
const rows=parseHistory(text.toString('utf8'),limit+1);
|
|
243
|
+
return historyPage(rows,snapshot,offset,limit);
|
|
244
|
+
}
|
|
245
|
+
export async function listReferences(repo,{signal}={}){
|
|
246
|
+
signal?.throwIfAborted();repo=await checkedRepo(repo,signal);
|
|
247
|
+
if(repo.type!=='git')throw new Error('References support Git only');
|
|
248
|
+
const raw=await git(repo.root,['for-each-ref','--sort=refname','--count=1001','--format='+REFS_FORMAT,'refs/heads/','refs/tags/'],MAX_TEXT,{signal});
|
|
249
|
+
return {references:parseReferences(raw.toString('utf8'))};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function getCommitDetails(repo,options={}) {
|
|
253
|
+
return commitDetails(repo,options,true);
|
|
254
|
+
}
|
|
255
|
+
async function commitDetails(repo,{commit,parentIndex=0,signal}={},includeMessage=false) {
|
|
256
|
+
if(typeof commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))throw new Error('Invalid commit id');
|
|
257
|
+
if(!Number.isInteger(parentIndex)||parentIndex<0)throw new Error('Invalid parent index');
|
|
258
|
+
signal?.throwIfAborted();
|
|
259
|
+
repo=await checkedRepo(repo,signal);
|
|
260
|
+
if(repo.type!=='git')throw new Error('Commit details support Git only');
|
|
261
|
+
const raw=await git(repo.root,['log','-z','--no-show-signature','--encoding=UTF-8','--max-count=1','--format='+HISTORY_FORMAT,commit,'--'],MAX_TEXT,{signal});
|
|
262
|
+
const metadata=parseHistory(raw.toString('utf8'),1)[0];
|
|
263
|
+
if(!metadata||metadata.id!==commit)throw new Error('Commit object required');
|
|
264
|
+
if(parentIndex>=Math.max(1,metadata.parents.length))throw new Error('Invalid parent index');
|
|
265
|
+
const parent=metadata.parents[parentIndex]??null;
|
|
266
|
+
const args=['diff-tree','--no-commit-id','--name-status','-z','-r','--no-ext-diff','--no-textconv','--find-renames',...(parent?[parent,commit]:['--root',commit]),'--'];
|
|
267
|
+
const changes=parseCommitChanges((await git(repo.root,args,MAX_TEXT,{signal})).toString('utf8'),commit+':'+(parent||'root'));
|
|
268
|
+
if(!includeMessage)return {...metadata,parent,parentIndex,changes};
|
|
269
|
+
let message='',messageTruncated=false;
|
|
270
|
+
try{message=(await git(repo.root,['log','--no-show-signature','--encoding=UTF-8','--max-count=1','--format=%B',commit,'--'],MAX_TEXT,{signal})).toString('utf8');}
|
|
271
|
+
catch(error){if(error.code!=='TOO_LARGE')throw error;messageTruncated=true;}
|
|
272
|
+
signal?.throwIfAborted();
|
|
273
|
+
return {...metadata,message,messageTruncated,parent,parentIndex,changes};
|
|
274
|
+
}
|
|
275
|
+
function parseCommitChanges(text,scope){
|
|
276
|
+
const fields=text.split(String.fromCharCode(0));
|
|
277
|
+
if(fields.pop()!=='')throw new Error('Truncated commit changes');
|
|
278
|
+
const changes=[];
|
|
279
|
+
for(let i=0;i<fields.length;){
|
|
280
|
+
const code=fields[i++];
|
|
281
|
+
if(!/^(?:[AMDT]|[RC][0-9]{1,3})$/.test(code))throw new Error('Invalid commit change status');
|
|
282
|
+
const first=fields[i++],renamed=/^[RC]/.test(code),file=renamed?fields[i++]:first;
|
|
283
|
+
if(!first||!file)throw new Error('Truncated commit path');
|
|
284
|
+
const entry={path:file,...(renamed?{oldPath:first}:{}),status:statuses[code[0]]||'modified'};
|
|
285
|
+
changes.push({...entry,id:identity(entry,scope)});
|
|
286
|
+
if(changes.length>10000)throw new Error('Commit exceeds file count limit');
|
|
287
|
+
}
|
|
288
|
+
return changes;
|
|
289
|
+
}
|
|
290
|
+
export async function getHistoricalTree(repo,{commit,signal}={}){
|
|
291
|
+
if(typeof commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))throw new Error('Invalid commit id');
|
|
292
|
+
repo=await checkedRepo(repo,signal);if(repo.type!=='git')throw new Error('Historical tree supports Git only');
|
|
293
|
+
const type=(await git(repo.root,['cat-file','-t',commit],MAX_TEXT,{signal})).toString('utf8').trim();if(type!=='commit')throw new Error('Commit object required');
|
|
294
|
+
const text=(await git(repo.root,['ls-tree','-r','-t','-z','--full-tree',commit],MAX_TEXT,{signal})).toString('utf8');
|
|
295
|
+
const fields=text.split(String.fromCharCode(0));if(fields.pop()!=='')throw new Error('Truncated historical tree');
|
|
296
|
+
if(fields.length>10000)throw new Error('Historical tree exceeds entry limit');
|
|
297
|
+
const entries=fields.map(record=>{
|
|
298
|
+
const tab=record.indexOf('\t'),head=record.slice(0,tab),file=record.slice(tab+1);
|
|
299
|
+
const match=/^(040000|100644|100755|120000|160000) (tree|blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})$/.exec(head);
|
|
300
|
+
if(tab<0||!file||!match)throw new Error('Invalid historical tree entry');
|
|
301
|
+
const [,mode,type,oid]=match;
|
|
302
|
+
if(type!==(mode==='040000'?'tree':mode==='160000'?'commit':'blob'))throw new Error('Invalid historical tree type');
|
|
303
|
+
return {path:file,mode,type,oid};
|
|
304
|
+
});
|
|
305
|
+
return {commit,entries};
|
|
306
|
+
}
|
|
307
|
+
export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',signal}={}){
|
|
308
|
+
if(!['left','right'].includes(side))throw new Error('Invalid image side');
|
|
309
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
310
|
+
const details=await commitDetails(repo,{commit,parentIndex,signal}),entry=details.changes.find(e=>e.id===id);
|
|
311
|
+
if(!entry)throw new Error('Change is not part of selected commit');
|
|
312
|
+
const revision=side==='left'?details.parent:commit,file=side==='left'?(entry.oldPath||entry.path):entry.path;
|
|
313
|
+
if(!revision||(side==='left'&&entry.status==='added')||(side==='right'&&entry.status==='deleted'))return {commit:revision,path:file,absent:true};
|
|
314
|
+
const listing=(await git(repo.root,['--literal-pathspecs','ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
|
|
315
|
+
const record=listing.split(String.fromCharCode(0)).find(row=>row.slice(row.indexOf('\t')+1)===file);
|
|
316
|
+
if(!record)throw new Error('Historical path missing');
|
|
317
|
+
const [mode,type,oid]=record.slice(0,record.indexOf('\t')).split(' ');
|
|
318
|
+
if(type!=='blob'||!['100644','100755'].includes(mode))throw new Error('Image preview requires a regular file');
|
|
319
|
+
const raw=await git(repo.root,['cat-file','blob',oid],MAX_TEXT,{signal});
|
|
320
|
+
const {data,...metadata}=await preparePng(raw,{signal});
|
|
321
|
+
return {commit:revision,path:file,oid,...metadata,base64:data.toString('base64')};
|
|
322
|
+
}
|
|
323
|
+
export async function getFileBlame(repo,{commit,parentIndex=0,id,signal}={}){
|
|
324
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
325
|
+
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
326
|
+
const entry=details.changes.find(row=>row.id===id);if(!entry)throw new Error('Change is not part of selected commit');
|
|
327
|
+
const result={commit,path:entry.path,lines:[],truncated:false};
|
|
328
|
+
if(entry.status==='deleted')return {...result,notice:'File is deleted at this commit; blame unavailable.'};
|
|
329
|
+
const blob=await historicalBlob(repo.root,commit,entry.path,signal);
|
|
330
|
+
if(blob.notice||blob.binary||blob.encoding!=='UTF-8')return {...result,notice:blob.notice||'Blame supports UTF-8 regular text only.'};
|
|
331
|
+
if(!blob.text)return result;
|
|
332
|
+
const count=blob.text.split('\n').length-(blob.text.endsWith('\n')?1:0),limit=Math.min(count,500);
|
|
333
|
+
let ignored;
|
|
334
|
+
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;}
|
|
335
|
+
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.'};
|
|
336
|
+
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});
|
|
337
|
+
return {...result,lines:parseBlame(output.toString('utf8'),limit),truncated:count>limit};
|
|
338
|
+
}
|
|
339
|
+
export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,signal}={}){
|
|
340
|
+
if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100)throw new Error('Invalid history pagination');
|
|
341
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
342
|
+
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
343
|
+
const entry=details.changes.find(row=>row.id===id);
|
|
344
|
+
if(!entry)throw new Error('Change is not part of selected commit');
|
|
345
|
+
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});
|
|
346
|
+
return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
|
|
347
|
+
}
|
|
348
|
+
export async function getRevisionChanges(repo,{base,target,signal}={}){
|
|
349
|
+
for(const oid of [base,target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw new Error('Invalid revision commit id');
|
|
350
|
+
signal?.throwIfAborted();repo=await checkedRepo(repo,signal);
|
|
351
|
+
if(repo.type!=='git')throw new Error('Revision comparison supports Git only');
|
|
352
|
+
for(const oid of new Set([base,target])){
|
|
353
|
+
const type=(await git(repo.root,['cat-file','-t',oid],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
354
|
+
if(type!=='commit')throw new Error('Commit object required');
|
|
355
|
+
}
|
|
356
|
+
const args=['diff-tree','--no-commit-id','--name-status','-z','-r','--no-ext-diff','--no-textconv','--find-renames',base,target,'--'];
|
|
357
|
+
const scope=JSON.stringify(['revision',repo.root,base,target]);
|
|
358
|
+
const changes=parseCommitChanges((await git(repo.root,args,MAX_TEXT,{signal})).toString('utf8'),scope);
|
|
359
|
+
return {base,target,changes};
|
|
360
|
+
}
|
|
220
361
|
function modeFor(repo, mode) {
|
|
221
362
|
if (!['all', 'staged', 'unstaged'].includes(mode)) throw new Error('Invalid comparison mode');
|
|
222
363
|
if (repo.type === 'svn' && mode !== 'all') throw new Error('SVN supports only all mode');
|
|
@@ -281,6 +422,36 @@ async function working(root, relative, svnLink = false) {
|
|
|
281
422
|
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(); }
|
|
282
423
|
}
|
|
283
424
|
async function gitBlob(root, revision, file) { return content(() => git(root, ['show', '--no-ext-diff', '--no-textconv', revision + ':' + file], MAX_TEXT)); }
|
|
425
|
+
async function historicalBlob(root,revision,file,signal){
|
|
426
|
+
const listing=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
|
|
427
|
+
const records=listing.split(String.fromCharCode(0)).filter(Boolean);
|
|
428
|
+
const record=records.find(row=>row.slice(row.indexOf(' ')+1)===file);
|
|
429
|
+
if(!record)throw new Error('Historical path missing');
|
|
430
|
+
const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
|
|
431
|
+
if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
|
|
432
|
+
if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
|
|
433
|
+
const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
|
|
434
|
+
if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal}={}){
|
|
438
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
|
|
439
|
+
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
440
|
+
const entry=details.changes.find(row=>row.id===id);
|
|
441
|
+
if(!entry)throw new Error('Historical change is not part of selected commit and parent');
|
|
442
|
+
const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal):{text:''};
|
|
443
|
+
const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal):{text:''};
|
|
444
|
+
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(' ')};
|
|
445
|
+
}
|
|
446
|
+
export async function getRevisionComparison(repo,{base,target,id,signal}={}){
|
|
447
|
+
if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid revision change id');
|
|
448
|
+
const details=await getRevisionChanges(repo,{base,target,signal});
|
|
449
|
+
const entry=details.changes.find(row=>row.id===id);
|
|
450
|
+
if(!entry)throw new Error('Change is not part of selected revision pair');
|
|
451
|
+
const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal):{text:''};
|
|
452
|
+
const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal):{text:''};
|
|
453
|
+
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(' ')};
|
|
454
|
+
}
|
|
284
455
|
async function properties(root, file, base) {
|
|
285
456
|
const result = parseXML(await svn(root, ['proplist', '--xml', '--verbose', ...(base ? ['--revision', 'BASE'] : []), '--', file + '@']));
|
|
286
457
|
const props = Object.create(null);
|