@feng0531/dsh-vcs 0.4.8 → 0.4.10
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 -3
- package/dist/frontend.json +1 -1
- package/dist/interface.js +3 -3
- package/dist/svn-host.mjs +1 -1
- package/git-history.mjs +6 -1
- package/index.mjs +285 -116
- package/package.json +1 -1
- package/vcs.mjs +103 -94
package/vcs.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import {parseCommitSignature} from './src/commit-signature.mjs';
|
|
|
3
3
|
import {parseSvnIdentity} from './src/svn-identity.mjs';
|
|
4
4
|
import {prepareRaster} from './image-preview.mjs';
|
|
5
5
|
import {git, svn, TIMEOUT} from './local-command.mjs';
|
|
6
|
-
import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT,validateDateBound} from './git-history.mjs';
|
|
6
|
+
import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
|
|
7
7
|
import * as fs from 'node:fs/promises';
|
|
8
8
|
import {constants as fileFlags} from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
@@ -13,6 +13,18 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
|
13
13
|
import {decode, MAX_TEXT, MAX_LARGE_TEXT, decodeLargeText} from './text-content.mjs';
|
|
14
14
|
import {inside, confined} from './repository-path.mjs';
|
|
15
15
|
import {readWorkspaceLargeText} from './workspace-large-text.mjs';
|
|
16
|
+
|
|
17
|
+
// Shared read-only adapter bounds. Values are part of the public contract:
|
|
18
|
+
// history pagination, per-result entry caps and segmented-preview byte budgets.
|
|
19
|
+
const MAX_HISTORY_OFFSET = 10000;
|
|
20
|
+
const MAX_HISTORY_PAGE = 100;
|
|
21
|
+
const MAX_RESULT_ENTRIES = 10000;
|
|
22
|
+
const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
|
|
23
|
+
const SEGMENT_CHUNK_BYTES = 64 * 1024;
|
|
24
|
+
const RENAME_CONFIRMATION_LIMIT = 32;
|
|
25
|
+
const METADATA_BATCH_SIZE = 64;
|
|
26
|
+
const METADATA_BATCH_BYTES = 24000;
|
|
27
|
+
|
|
16
28
|
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
|
|
17
29
|
|
|
18
30
|
|
|
@@ -183,6 +195,34 @@ async function checkedRepo(repo, signal, budget) {
|
|
|
183
195
|
if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
|
|
184
196
|
return found;
|
|
185
197
|
}
|
|
198
|
+
function assertGit(repo, message) {
|
|
199
|
+
if (repo.type !== 'git') throw new Error(message);
|
|
200
|
+
return repo;
|
|
201
|
+
}
|
|
202
|
+
async function checkedGitRepo(repo, signal, message, budget) {
|
|
203
|
+
return assertGit(await checkedRepo(repo, signal, budget), message);
|
|
204
|
+
}
|
|
205
|
+
// Probe --since-as-filter support so older Git cannot run a silently unfiltered query.
|
|
206
|
+
async function assertSinceAsFilterSupport(repo, signal, label) {
|
|
207
|
+
signal?.throwIfAborted();
|
|
208
|
+
try { await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal}); }
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (e.code === 'VCS_COMMAND') throw new Error('Date-range ' + label + ' requires Git 2.37 or newer (--since-as-filter).');
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Resolve HEAD^{commit}; only an unborn symbolic HEAD is absent, never corruption.
|
|
215
|
+
async function resolveHeadCommit(run) {
|
|
216
|
+
try { return (await run(['rev-parse','--verify','HEAD^{commit}'])).toString('utf8').trim(); }
|
|
217
|
+
catch (e) {
|
|
218
|
+
if (e.code !== 'VCS_COMMAND') throw e;
|
|
219
|
+
// Distinguish an unborn symbolic HEAD from corruption or other command failures.
|
|
220
|
+
const ref=(await run(['symbolic-ref','-q','HEAD'])).toString('utf8').trim();
|
|
221
|
+
try { await run(['show-ref','--verify','--quiet',ref]); }
|
|
222
|
+
catch (missing) { if (missing.code === 'VCS_COMMAND' && missing.exitCode === 1) return null; throw missing; }
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
186
226
|
export async function getSvnIdentity(repo,{signal}={}){
|
|
187
227
|
repo=await checkedRepo(repo,signal);if(repo.type!=='svn')throw Error('SVN working copy required');
|
|
188
228
|
const result=parseSvnIdentity((await svn(repo.root,['info','--xml','--', '.'],MAX_TEXT,{signal})).toString('utf8'));
|
|
@@ -190,42 +230,28 @@ export async function getSvnIdentity(repo,{signal}={}){
|
|
|
190
230
|
}
|
|
191
231
|
export async function listHistory(repo, {snapshot,offset=0,limit=50,search,signal} = {}) {
|
|
192
232
|
const query=historySearchArgs(search);
|
|
193
|
-
if(!Number.isInteger(offset)||offset<0||offset>
|
|
194
|
-
if(snapshot!==undefined && (typeof snapshot!=='string'
|
|
233
|
+
if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE) throw new Error('Invalid history pagination');
|
|
234
|
+
if(snapshot!==undefined && (typeof snapshot!=='string'||!isObjectId(snapshot))) throw new Error('Invalid history snapshot');
|
|
195
235
|
if(signal?.aborted) throw Object.assign(new Error('History cancelled'),{code:'ABORT_ERR'});
|
|
196
|
-
repo=await
|
|
197
|
-
if(
|
|
198
|
-
if(query.args.some(a=>a.startsWith('--since-as-filter='))){
|
|
199
|
-
signal?.throwIfAborted();
|
|
200
|
-
try{await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal});}
|
|
201
|
-
catch(e){if(e.code==='VCS_COMMAND')throw new Error('Date-range history search requires Git 2.37 or newer (--since-as-filter).');throw e;}
|
|
202
|
-
}
|
|
236
|
+
repo=await checkedGitRepo(repo,signal,'History currently supports Git only');
|
|
237
|
+
if(query.args.some(a=>a.startsWith('--since-as-filter='))) await assertSinceAsFilterSupport(repo,signal,'history search');
|
|
203
238
|
if(!snapshot){
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if(e.code!=='VCS_COMMAND')throw e;
|
|
207
|
-
// Distinguish an unborn symbolic HEAD from corruption or other command failures.
|
|
208
|
-
const ref=(await git(repo.root,['symbolic-ref','-q','HEAD'],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
209
|
-
try{await git(repo.root,['show-ref','--verify','--quiet',ref],MAX_TEXT,{signal});}
|
|
210
|
-
catch(missing){if(missing.code==='VCS_COMMAND'&&missing.exitCode===1)return {snapshot:null,commits:[],nextOffset:null};throw missing;}
|
|
211
|
-
throw e;
|
|
212
|
-
}
|
|
239
|
+
snapshot=await resolveHeadCommit(args=>git(repo.root,args,MAX_TEXT,{signal}));
|
|
240
|
+
if(snapshot===null)return {snapshot:null,commits:[],nextOffset:null};
|
|
213
241
|
}
|
|
214
242
|
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,...query.args,snapshot,'--',...query.paths],MAX_TEXT,{signal});
|
|
215
243
|
const rows=parseHistory(text.toString('utf8'),limit+1);
|
|
216
244
|
return historyPage(rows,snapshot,offset,limit);
|
|
217
245
|
}
|
|
218
246
|
export async function listReferences(repo,{signal}={}){
|
|
219
|
-
signal?.throwIfAborted();repo=await
|
|
220
|
-
if(repo.type!=='git')throw new Error('References support Git only');
|
|
247
|
+
signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'References support Git only');
|
|
221
248
|
const raw=await git(repo.root,['for-each-ref','--sort=refname','--count=1001','--format='+REFS_FORMAT,'refs/heads/','refs/tags/','refs/remotes/'],MAX_TEXT,{signal});
|
|
222
249
|
return {references:parseReferences(raw.toString('utf8'))};
|
|
223
250
|
}
|
|
224
251
|
|
|
225
252
|
const STASH_FORMAT='%H%x00%gs%x00%gd';
|
|
226
253
|
export async function listStashes(repo,{signal}={}){
|
|
227
|
-
signal?.throwIfAborted();repo=await
|
|
228
|
-
if(repo.type!=='git')throw new Error('Stash supports Git only');
|
|
254
|
+
signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Stash supports Git only');
|
|
229
255
|
const text=(await git(repo.root,['stash','list','--format='+STASH_FORMAT],MAX_TEXT,{signal})).toString('utf8');
|
|
230
256
|
if(!text.trim())return {stashes:[]};
|
|
231
257
|
const stashes=[];
|
|
@@ -234,7 +260,7 @@ export async function listStashes(repo,{signal}={}){
|
|
|
234
260
|
const fields=line.split('\0');
|
|
235
261
|
if(fields.length!==3)throw new Error('Invalid stash entry');
|
|
236
262
|
const [id,message,ref]=fields;
|
|
237
|
-
if(
|
|
263
|
+
if(!isObjectId(id))throw new Error('Invalid stash entry');
|
|
238
264
|
stashes.push({id,message:message||ref,ref});
|
|
239
265
|
if(stashes.length>1000)throw new Error('Stash exceeds entry limit');
|
|
240
266
|
}
|
|
@@ -244,14 +270,13 @@ export async function listStashes(repo,{signal}={}){
|
|
|
244
270
|
export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
|
|
245
271
|
signal?.throwIfAborted();
|
|
246
272
|
if(typeof query!=='string'||!query||query.length>1024||/[\0\r\n]/.test(query))throw new Error('Invalid content search query');
|
|
247
|
-
repo=await
|
|
248
|
-
if(repo.type!=='git')throw new Error('Content search supports Git only');
|
|
273
|
+
repo=await checkedGitRepo(repo,signal,'Content search supports Git only');
|
|
249
274
|
let snapshot=commit;
|
|
250
275
|
if(!snapshot){
|
|
251
276
|
try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
|
|
252
277
|
catch{throw new Error('Content search requires a resolved HEAD commit');}
|
|
253
278
|
}
|
|
254
|
-
if(
|
|
279
|
+
if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
|
|
255
280
|
let text;
|
|
256
281
|
try{text=(await git(repo.root,['-c','grep.column=false','grep','--no-color','-F','-n','-I','-z','--max-count=100','-e',query,snapshot,'--'],MAX_TEXT,{signal})).toString('utf8');}
|
|
257
282
|
catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {matches:[]};throw error;}
|
|
@@ -276,11 +301,10 @@ export async function getCommitDetails(repo,options={}) {
|
|
|
276
301
|
return commitDetails(repo,options,true);
|
|
277
302
|
}
|
|
278
303
|
async function commitDetails(repo,{commit,parentIndex=0,signal}={},includeMessage=false) {
|
|
279
|
-
if(typeof commit!=='string'
|
|
304
|
+
if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
|
|
280
305
|
if(!Number.isInteger(parentIndex)||parentIndex<0)throw new Error('Invalid parent index');
|
|
281
306
|
signal?.throwIfAborted();
|
|
282
|
-
repo=await
|
|
283
|
-
if(repo.type!=='git')throw new Error('Commit details support Git only');
|
|
307
|
+
repo=await checkedGitRepo(repo,signal,'Commit details support Git only');
|
|
284
308
|
const raw=await git(repo.root,['log','-z','--no-show-signature','--encoding=UTF-8','--max-count=1','--format='+HISTORY_FORMAT,commit,'--'],MAX_TEXT,{signal});
|
|
285
309
|
const metadata=parseHistory(raw.toString('utf8'),1)[0];
|
|
286
310
|
if(!metadata||metadata.id!==commit)throw new Error('Commit object required');
|
|
@@ -315,22 +339,30 @@ function parseCommitChanges(text,scope){
|
|
|
315
339
|
if(!first||!file)throw new Error('Truncated commit path');
|
|
316
340
|
const entry={path:file,...(renamed?{oldPath:first}:{}),status:statuses[code[0]]||'modified'};
|
|
317
341
|
changes.push({...entry,id:identity(entry,scope)});
|
|
318
|
-
if(changes.length>
|
|
342
|
+
if(changes.length>MAX_RESULT_ENTRIES)throw new Error('Commit exceeds file count limit');
|
|
319
343
|
}
|
|
320
344
|
return changes;
|
|
321
345
|
}
|
|
322
346
|
export async function getHistoricalSegment(repo,{commit,path:file,offset=0,signal}={}){
|
|
323
|
-
if(!Number.isInteger(offset)||offset<0||offset>
|
|
347
|
+
if(!Number.isInteger(offset)||offset<0||offset>MAX_SEGMENT_BYTES)throw Error('Invalid segment offset');
|
|
324
348
|
if(typeof file!=='string'||!file||file.includes('\0'))throw Error('Invalid historical path');
|
|
325
|
-
repo=await checkedRepo(repo,signal);
|
|
349
|
+
repo=await checkedRepo(repo,signal);
|
|
350
|
+
const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
|
|
326
351
|
if(!entry||entry.type!=='blob'||!['100644','100755'].includes(entry.mode))throw Error('Segment requires a selected committed regular file');
|
|
327
|
-
const sizeText=(await git(repo.root,['cat-file','-s',entry.oid],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
328
|
-
if(
|
|
329
|
-
const
|
|
352
|
+
const sizeText=(await git(repo.root,['cat-file','-s',entry.oid],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
353
|
+
if(!/^\d+$/.test(sizeText))throw Error('Invalid object size');
|
|
354
|
+
const size=Number(sizeText);
|
|
355
|
+
if(size>MAX_SEGMENT_BYTES)throw Error('Segmented file exceeds 16 MiB limit');
|
|
356
|
+
if(offset>size)throw Error('Segment offset exceeds file size');
|
|
357
|
+
const buffer=await git(repo.root,['cat-file','blob',entry.oid],MAX_SEGMENT_BYTES,{signal});
|
|
358
|
+
if(buffer.length!==size)throw Error('Historical blob size mismatch');
|
|
330
359
|
if(buffer.includes(0)||buffer.some(byte=>byte<32&&![9,10,12,13].includes(byte)))throw Error('Segments support UTF-8 text only');
|
|
331
|
-
new TextDecoder('utf-8',{fatal:true}).decode(buffer);
|
|
332
|
-
|
|
333
|
-
|
|
360
|
+
new TextDecoder('utf-8',{fatal:true}).decode(buffer);
|
|
361
|
+
if(offset<size&&(buffer[offset]&192)===128)throw Error('Segment offset splits UTF-8 character');
|
|
362
|
+
let end=Math.min(size,offset+SEGMENT_CHUNK_BYTES);
|
|
363
|
+
while(end<size&&(buffer[end]&192)===128)end--;
|
|
364
|
+
const text=new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(offset,end));
|
|
365
|
+
signal?.throwIfAborted();
|
|
334
366
|
return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end<size?end:null,text};
|
|
335
367
|
}
|
|
336
368
|
export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
|
|
@@ -344,12 +376,12 @@ export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
|
|
|
344
376
|
return {commit,path:file,oid:entry.oid,...result};
|
|
345
377
|
}
|
|
346
378
|
export async function getHistoricalTree(repo,{commit,signal}={}){
|
|
347
|
-
if(typeof commit!=='string'
|
|
348
|
-
repo=await
|
|
379
|
+
if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
|
|
380
|
+
repo=await checkedGitRepo(repo,signal,'Historical tree supports Git only');
|
|
349
381
|
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');
|
|
350
382
|
const text=(await git(repo.root,['ls-tree','-r','-t','-z','--full-tree',commit],MAX_TEXT,{signal})).toString('utf8');
|
|
351
383
|
const fields=text.split(String.fromCharCode(0));if(fields.pop()!=='')throw new Error('Truncated historical tree');
|
|
352
|
-
if(fields.length>
|
|
384
|
+
if(fields.length>MAX_RESULT_ENTRIES)throw new Error('Historical tree exceeds entry limit');
|
|
353
385
|
const entries=fields.map(record=>{
|
|
354
386
|
const tab=record.indexOf('\t'),head=record.slice(0,tab),file=record.slice(tab+1);
|
|
355
387
|
const match=/^(040000|100644|100755|120000|160000) (tree|blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})$/.exec(head);
|
|
@@ -362,7 +394,7 @@ export async function getHistoricalTree(repo,{commit,signal}={}){
|
|
|
362
394
|
}
|
|
363
395
|
export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',signal}={}){
|
|
364
396
|
if(!['left','right'].includes(side))throw new Error('Invalid image side');
|
|
365
|
-
if(typeof id!=='string'
|
|
397
|
+
if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
|
|
366
398
|
const details=await commitDetails(repo,{commit,parentIndex,signal}),entry=details.changes.find(e=>e.id===id);
|
|
367
399
|
if(!entry)throw new Error('Change is not part of selected commit');
|
|
368
400
|
const revision=side==='left'?details.parent:commit,file=side==='left'?(entry.oldPath||entry.path):entry.path;
|
|
@@ -382,8 +414,8 @@ async function historicalImage(repo,revision,file,signal){
|
|
|
382
414
|
async function workspacePng(buffer,signal){const {data,...metadata}=await prepareRaster(buffer,{signal});return {...metadata,base64:data.toString('base64')};}
|
|
383
415
|
// Workspace image reads use immutable HEAD/index blobs and a checked regular-file handle.
|
|
384
416
|
export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}={}){
|
|
385
|
-
if(typeof id!=='string'
|
|
386
|
-
repo=await
|
|
417
|
+
if(typeof id!=='string'||!isChangeId(id)||!['left','right'].includes(side))throw Error('Invalid workspace image selection');
|
|
418
|
+
repo=await checkedGitRepo(repo,signal,'Workspace PNG supports Git only');modeFor(repo,mode);
|
|
387
419
|
const entry=(await changes(repo,mode)).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status.');
|
|
388
420
|
if(entry.status==='conflicted')throw Error('Conflicted images are not supported');
|
|
389
421
|
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;}};
|
|
@@ -419,7 +451,7 @@ export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}
|
|
|
419
451
|
}
|
|
420
452
|
export async function getRevisionImage(repo,{base,target,id,side='right',signal}={}){
|
|
421
453
|
if(!['left','right'].includes(side))throw Error('Invalid image side');
|
|
422
|
-
if(typeof id!=='string'
|
|
454
|
+
if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid revision change id');
|
|
423
455
|
const details=await getRevisionChanges(repo,{base,target,signal}),entry=details.changes.find(row=>row.id===id);
|
|
424
456
|
if(!entry)throw Error('Change is not part of selected revision pair');
|
|
425
457
|
const revision=side==='left'?base:target,file=side==='left'?(entry.oldPath||entry.path):entry.path;
|
|
@@ -428,7 +460,7 @@ export async function getRevisionImage(repo,{base,target,id,side='right',signal}
|
|
|
428
460
|
}
|
|
429
461
|
export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lineLimit=500,signal}={}){
|
|
430
462
|
if(!Number.isInteger(startLine)||startLine<1||startLine>100001||!Number.isInteger(lineLimit)||lineLimit<1||lineLimit>500)throw Error('Invalid blame line window');
|
|
431
|
-
if(typeof id!=='string'
|
|
463
|
+
if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
|
|
432
464
|
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
433
465
|
const entry=details.changes.find(row=>row.id===id);if(!entry)throw new Error('Change is not part of selected commit');
|
|
434
466
|
const result={commit,path:entry.path,lines:[],truncated:false,startLine,nextLine:null};
|
|
@@ -446,38 +478,16 @@ export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lin
|
|
|
446
478
|
return {...result,lines:parseBlame(output.toString('utf8'),limit),totalLines:count,nextLine:endLine<count&&endLine<100001?endLine+1:null,truncated:endLine<count};
|
|
447
479
|
}
|
|
448
480
|
export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,follow=false,since,until,signal}={}){
|
|
449
|
-
if(!Number.isInteger(offset)||offset<0||offset>
|
|
450
|
-
if(typeof id!=='string'
|
|
481
|
+
if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE)throw new Error('Invalid history pagination');
|
|
482
|
+
if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
|
|
451
483
|
if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
|
|
452
484
|
const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
|
|
453
485
|
if((sinceArg||untilArg)&&follow)throw Error('Date-range file history is not combined with rename following.');
|
|
454
486
|
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
455
487
|
const entry=details.changes.find(row=>row.id===id);
|
|
456
488
|
if(!entry)throw new Error('Change is not part of selected commit');
|
|
457
|
-
if(follow){
|
|
458
|
-
|
|
459
|
-
const followOptions=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Rename tracing timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
|
|
460
|
-
const output=await git(repo.root,['--literal-pathspecs','log','-z','--follow','--first-parent','--diff-merges=first-parent','--find-renames=50%','-l100','--name-status','--no-ext-diff','--no-textconv','--no-show-signature','--encoding=UTF-8','--max-count='+count,'--format='+HISTORY_FORMAT,commit,'--',entry.path],MAX_TEXT,followOptions());
|
|
461
|
-
const chain=parseFollowHistory(output.toString('utf8'),entry.path,count,{similarity:true});
|
|
462
|
-
let checks=0;
|
|
463
|
-
for(let index=0;index<chain.length;index++){
|
|
464
|
-
const row=chain[index];if(!row.oldPath)continue;
|
|
465
|
-
let boundary;
|
|
466
|
-
if(++checks>32)boundary='rename-check-limit';
|
|
467
|
-
else {
|
|
468
|
-
// Confirm the whole transition, not Git's selected-path heuristic alone.
|
|
469
|
-
// A single deletion and addition excludes competing deleted sources.
|
|
470
|
-
const raw=await git(repo.root,['diff-tree','--no-commit-id','--name-status','-z','-r','--no-renames','--no-ext-diff','--no-textconv',row.parents[0],row.id,'--'],MAX_TEXT,followOptions());
|
|
471
|
-
const changes=parseCommitChanges(raw.toString('utf8'),row.id);
|
|
472
|
-
const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
|
|
473
|
-
if(removed.length!==1||added.length!==1||removed[0].path!==row.oldPath||added[0].path!==row.path)boundary='ambiguous-rename';
|
|
474
|
-
}
|
|
475
|
-
if(boundary){chain[index]={...row,boundary};chain.length=index+1;break;}
|
|
476
|
-
}
|
|
477
|
-
const rows=chain.slice(offset);
|
|
478
|
-
return {...historyPage(rows,commit,offset,limit),path:entry.path,followsRenames:true,followPolicy:'unique-similarity-first-parent'};
|
|
479
|
-
}
|
|
480
|
-
if(sinceArg){signal?.throwIfAborted();try{await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal});}catch(e){if(e.code==='VCS_COMMAND')throw new Error('Date-range file history requires Git 2.37 or newer (--since-as-filter).');throw e;}}
|
|
489
|
+
if(follow)return followHistoricalPath(repo,{commit,file:entry.path,offset,limit,signal});
|
|
490
|
+
if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'file history');
|
|
481
491
|
const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
|
|
482
492
|
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,...dateArgs,commit,'--',entry.path],MAX_TEXT,{signal});
|
|
483
493
|
return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
|
|
@@ -493,8 +503,10 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
|
|
|
493
503
|
for(let index=0;index<chain.length;index++){
|
|
494
504
|
const row=chain[index];if(!row.oldPath)continue;
|
|
495
505
|
let boundary;
|
|
496
|
-
if(++checks>
|
|
506
|
+
if(++checks>RENAME_CONFIRMATION_LIMIT)boundary='rename-check-limit';
|
|
497
507
|
else {
|
|
508
|
+
// Confirm the whole transition, not Git's selected-path heuristic alone.
|
|
509
|
+
// A single deletion and addition excludes competing deleted sources.
|
|
498
510
|
const raw=await git(repo.root,['diff-tree','--no-commit-id','--name-status','-z','-r','--no-renames','--no-ext-diff','--no-textconv',row.parents[0],row.id,'--'],MAX_TEXT,followOptions());
|
|
499
511
|
const changes=parseCommitChanges(raw.toString('utf8'),row.id);
|
|
500
512
|
const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
|
|
@@ -507,28 +519,26 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
|
|
|
507
519
|
|
|
508
520
|
// Explicit historical path history; the path must exist in the selected commit.
|
|
509
521
|
export async function listPathHistory(repo,{commit,path:file,offset=0,limit=50,follow=false,since,until,signal}={}){
|
|
510
|
-
if(typeof commit!=='string'
|
|
522
|
+
if(typeof commit!=='string'||!isObjectId(commit))throw Error('Invalid commit id');
|
|
511
523
|
if(typeof file!=='string'||!file||file.length>32768||file.includes('\0')||file.startsWith('/')||file.split('/').some(part=>!part||part==='.'||part==='..'))throw Error('Invalid historical path');
|
|
512
524
|
if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
|
|
513
525
|
const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
|
|
514
526
|
if((sinceArg||untilArg)&&follow)throw Error('Date-range path history is not combined with rename following.');
|
|
515
|
-
if(!Number.isInteger(offset)||offset<0||offset>
|
|
516
|
-
repo=await
|
|
517
|
-
if(repo.type!=='git')throw Error('Historical path history supports Git only');
|
|
527
|
+
if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE)throw Error('Invalid history pagination');
|
|
528
|
+
repo=await checkedGitRepo(repo,signal,'Historical path history supports Git only');
|
|
518
529
|
const listing=(await git(repo.root,['ls-tree','-z',commit,'--',file],MAX_TEXT,{signal})).toString('utf8');
|
|
519
530
|
const record=listing.split('\0').filter(Boolean).find(row=>row.slice(row.indexOf('\t')+1)===file);
|
|
520
531
|
if(!record)throw Error('Path does not exist in the selected commit');
|
|
521
532
|
const mode=record.slice(0,record.indexOf('\t')).split(' ')[0];
|
|
522
533
|
if(follow&&!['100644','100755','120000'].includes(mode))throw Error('Rename following requires a committed regular file or link');
|
|
523
|
-
if(sinceArg)
|
|
534
|
+
if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'path history');
|
|
524
535
|
const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
|
|
525
536
|
return follow?followHistoricalPath(repo,{commit,file,offset,limit,signal}):{...historyPage(parseHistory((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,...dateArgs,commit,'--',file],MAX_TEXT,{signal})).toString('utf8'),limit+1),commit,offset,limit),path:file,followsRenames:false};
|
|
526
537
|
}
|
|
527
538
|
|
|
528
539
|
export async function getRevisionChanges(repo,{base,target,signal}={}){
|
|
529
|
-
for(const oid of [base,target])if(typeof oid!=='string'
|
|
530
|
-
signal?.throwIfAborted();repo=await
|
|
531
|
-
if(repo.type!=='git')throw new Error('Revision comparison supports Git only');
|
|
540
|
+
for(const oid of [base,target])if(typeof oid!=='string'||!isObjectId(oid))throw new Error('Invalid revision commit id');
|
|
541
|
+
signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Revision comparison supports Git only');
|
|
532
542
|
for(const oid of new Set([base,target])){
|
|
533
543
|
const type=(await git(repo.root,['cat-file','-t',oid],MAX_TEXT,{signal})).toString('utf8').trim();
|
|
534
544
|
if(type!=='commit')throw new Error('Commit object required');
|
|
@@ -553,9 +563,9 @@ async function annotateSubmodules(repo, entries, options) {
|
|
|
553
563
|
const files=entries.filter(entry=>entry.status!=='conflicted').map(entry=>entry.path);
|
|
554
564
|
for(let from=0;from<files.length;){
|
|
555
565
|
check();const batch=[];let bytes=0;
|
|
556
|
-
while(from<files.length&&batch.length<
|
|
566
|
+
while(from<files.length&&batch.length<METADATA_BATCH_SIZE){const file=files[from],size=Buffer.byteLength(file,'utf8')+1;if(size>METADATA_BATCH_BYTES)throw Error('Selected path exceeds metadata budget');if(bytes+size>METADATA_BATCH_BYTES)break;batch.push(file);bytes+=size;from++;}
|
|
557
567
|
const listing=(await git(repo.root,['ls-files','--stage','-z','--',...batch],MAX_TEXT,{...options,timeoutMs:deadline-Date.now()})).toString('utf8');
|
|
558
|
-
for(const record of listing.split('\0')){const tab=record.indexOf('\t');if(tab<0)continue;const [mode,oid,stage]=record.slice(0,tab).split(' '),file=record.slice(tab+1);if(stage==='0'&&mode==='160000'&&batch.includes(file)
|
|
568
|
+
for(const record of listing.split('\0')){const tab=record.indexOf('\t');if(tab<0)continue;const [mode,oid,stage]=record.slice(0,tab).split(' '),file=record.slice(tab+1);if(stage==='0'&&mode==='160000'&&batch.includes(file)&&isObjectId(oid))pointers.set(file,oid);}
|
|
559
569
|
}
|
|
560
570
|
if (!pointers.size) return;
|
|
561
571
|
const initialized = [];
|
|
@@ -576,8 +586,8 @@ async function annotateSubmodules(repo, entries, options) {
|
|
|
576
586
|
// Bounded, read-only submodule worktree state from the parent's own `git status`
|
|
577
587
|
// (never runs Git inside the submodule): --porcelain=v2 exposes `S` fields for new
|
|
578
588
|
// commits, modified content and untracked content in an initialized checkout.
|
|
579
|
-
for (let from = 0; from < initialized.length; from +=
|
|
580
|
-
check();const batch = initialized.slice(from, from +
|
|
589
|
+
for (let from = 0; from < initialized.length; from += METADATA_BATCH_SIZE) {
|
|
590
|
+
check();const batch = initialized.slice(from, from + METADATA_BATCH_SIZE);
|
|
581
591
|
const out = (await git(repo.root, ['status', '--porcelain=v2', '-z', '--ignore-submodules=none', '--', ...batch.map(entry => entry.path)], MAX_TEXT, {...options, timeoutMs: deadline - Date.now()})).toString('utf8');
|
|
582
592
|
const records=out.split(String.fromCharCode(0));
|
|
583
593
|
for(let i=0;i<records.length;i++){
|
|
@@ -661,7 +671,7 @@ async function gitBlob(root, revision, file, options) {
|
|
|
661
671
|
for(const row of raw.split('\0')){const tab=row.indexOf('\t');if(tab<0||row.slice(tab+1)!==file)continue;const [mode,oid,stage]=row.slice(0,tab).split(' ');if(stage==='0')record={mode,oid,type:mode==='160000'?'commit':'blob'};}
|
|
662
672
|
if(!record)return {text:'',notice:'No stage-0 Index entry; conflict stages are not inferred.'};
|
|
663
673
|
}
|
|
664
|
-
if(
|
|
674
|
+
if(!isObjectId(record.oid))throw Error('Invalid selected object');
|
|
665
675
|
if(record.mode==='160000')return {text:'Submodule '+record.oid,notice:'Recorded gitlink pointer; nested checkout commit and contents are not loaded by this preview.'};
|
|
666
676
|
if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',notice:'Unsupported selected object; contents not loaded.'};
|
|
667
677
|
const body=await content(()=>git(root,['cat-file','blob',record.oid],MAX_TEXT,options));
|
|
@@ -693,7 +703,7 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
|
|
|
693
703
|
}
|
|
694
704
|
export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
|
|
695
705
|
if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
|
|
696
|
-
if(typeof id!=='string'
|
|
706
|
+
if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
|
|
697
707
|
const details=await commitDetails(repo,{commit,parentIndex,signal});
|
|
698
708
|
const entry=details.changes.find(row=>row.id===id);
|
|
699
709
|
if(!entry)throw new Error('Historical change is not part of selected commit and parent');
|
|
@@ -703,7 +713,7 @@ export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,l
|
|
|
703
713
|
}
|
|
704
714
|
export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
|
|
705
715
|
if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
|
|
706
|
-
if(typeof id!=='string'
|
|
716
|
+
if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid revision change id');
|
|
707
717
|
const details=await getRevisionChanges(repo,{base,target,signal});
|
|
708
718
|
const entry=details.changes.find(row=>row.id===id);
|
|
709
719
|
if(!entry)throw new Error('Change is not part of selected revision pair');
|
|
@@ -722,13 +732,12 @@ async function properties(root, file, base, signal) {
|
|
|
722
732
|
async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
|
|
723
733
|
const deadline=Date.now()+TIMEOUT;
|
|
724
734
|
const options=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Workspace comparison timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
|
|
725
|
-
repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);
|
|
726
|
-
if(repo.type!=='git')throw Error('Large workspace comparison supports Git only');
|
|
735
|
+
repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);assertGit(repo,'Large workspace comparison supports Git only');
|
|
727
736
|
const run=args=>git(repo.root,args,MAX_TEXT,options());
|
|
728
737
|
const status=()=>changes(repo,mode,options());
|
|
729
738
|
const entry=(await status()).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status');
|
|
730
739
|
if(entry.status==='conflicted')throw Error('Large conflicted comparisons are not supported');
|
|
731
|
-
const head=
|
|
740
|
+
const head=()=>resolveHeadCommit(run);
|
|
732
741
|
const revision=await head(),indexArgs=['ls-files','--stage','-z','--',entry.path],index=await run(indexArgs);
|
|
733
742
|
const readIndex=async()=>{
|
|
734
743
|
const rows=index.toString('utf8').split('\0').filter(Boolean);if(rows.length!==1)throw Error('Large comparison requires one stage0 Index entry');
|
|
@@ -752,8 +761,8 @@ async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
|
|
|
752
761
|
/** Explicit Index stage snapshot; never reads the selected working file or submodule. */
|
|
753
762
|
export async function getConflictStages(repo,{mode='all',id,signal}={}){
|
|
754
763
|
const deadline=Date.now()+TIMEOUT,budget=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Conflict stage read timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
|
|
755
|
-
if(typeof id!=='string'
|
|
756
|
-
repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);
|
|
764
|
+
if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid conflict change id');
|
|
765
|
+
repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);assertGit(repo,'Conflict stages support Git only');
|
|
757
766
|
const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw Error('Selected conflict no longer exists; refresh status.');
|
|
758
767
|
await confined(repo.root,entry.path);
|
|
759
768
|
const list=()=>git(repo.root,['ls-files','--unmerged','-z','--',entry.path],MAX_TEXT,budget());
|